commit 97f329c8254fc2cd90fccd5a490eb1d3e948b473 Author: eric Date: Sat Apr 4 05:57:58 2026 +0200 feat: ui diff --git a/.agent/controller-loop/task.toon b/.agent/controller-loop/task.toon new file mode 100644 index 0000000..a4f726b --- /dev/null +++ b/.agent/controller-loop/task.toon @@ -0,0 +1,9 @@ +engine: "data-driven-v1" +goal_file: ".agent/controllers/keystone-seam-audit/goal.md" +plan_file: ".agent/controllers/keystone-seam-audit/plan.toon" +state_file: ".agent/controllers/keystone-seam-audit/state.toon" +standards_file: ".agent/controllers/keystone-seam-audit/standards.md" +branch: "codex/keystone-seam-audit" +continue_until: "fixed-point" +max_runs: 12 +max_wall_clock: 4h \ No newline at end of file diff --git a/.agent/controllers/AGENTS.md b/.agent/controllers/AGENTS.md new file mode 100644 index 0000000..683191b --- /dev/null +++ b/.agent/controllers/AGENTS.md @@ -0,0 +1,18 @@ +# AGENTS.md + +## Scope + +These instructions apply under `.agent/controllers/`. + +## What Belongs Here + +- checked-in controller state files +- checked-in controller plans +- controller goals and standards documents + +## Rules + +- Keep controller state in TOON and checked in. +- Keep plans in TOON and goals/standards in Markdown. +- Do not add TypeScript, JavaScript, or prompt-template assets here. +- The Rust engine owns runtime logic; `.agent/controllers/` holds data, not controller code. diff --git a/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/goal.md b/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/goal.md new file mode 100644 index 0000000..1315b20 --- /dev/null +++ b/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/goal.md @@ -0,0 +1,3 @@ +# Goal + +Describe the goal for this controller. diff --git a/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/plan.toon b/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/plan.toon new file mode 100644 index 0000000..c0524ef --- /dev/null +++ b/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/plan.toon @@ -0,0 +1,3 @@ +version: 1 +goal_summary: No plan yet +steps[0]: \ No newline at end of file diff --git a/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/standards.md b/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/standards.md new file mode 100644 index 0000000..e7b6088 --- /dev/null +++ b/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/standards.md @@ -0,0 +1,5 @@ +# Standards + +- Keep code maintainable. +- Avoid one-off hacks. +- Leave tests green. diff --git a/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/state.toon b/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/state.toon new file mode 100644 index 0000000..fdb40a3 --- /dev/null +++ b/.agent/controllers/code-refactoring-act-as-as-a-senior-software-arc/state.toon @@ -0,0 +1,21 @@ +version: 1 +phase: planning +goal_status: unknown +goal_revision: 0 +current_step_id: null +iteration: 0 +replan_required: false +completed_steps[0]: +blocked_steps[0]: +last_verification: null +last_cleanup_summary: null +last_full_test_summary: null +history[0]: +notes[0]: +planning_session: + pending_question: null + transcript[0]: +started_at: "1775272586" +last_usage_refresh_at: "1775272706" +last_usage_input_tokens: null +last_usage_output_tokens: null \ No newline at end of file diff --git a/.agent/controllers/controller-loop/goal.md b/.agent/controllers/controller-loop/goal.md new file mode 100644 index 0000000..3fe078a --- /dev/null +++ b/.agent/controllers/controller-loop/goal.md @@ -0,0 +1,4 @@ +# Goal + +Rewrite `codex-controller-loop` as a Rust TUI-first autonomous controller with TOON-backed machine state and a hard planning/execution phase boundary. + diff --git a/.agent/controllers/controller-loop/plan.toon b/.agent/controllers/controller-loop/plan.toon new file mode 100644 index 0000000..1ad859f --- /dev/null +++ b/.agent/controllers/controller-loop/plan.toon @@ -0,0 +1,3 @@ +version: 1 +goal_summary: Rust TUI-first autonomous controller +steps[0]: diff --git a/.agent/controllers/controller-loop/standards.md b/.agent/controllers/controller-loop/standards.md new file mode 100644 index 0000000..9ecb78f --- /dev/null +++ b/.agent/controllers/controller-loop/standards.md @@ -0,0 +1,8 @@ +# Standards + +- Keep the Rust code modular and readable. +- Treat planning as the only user-input phase. +- Treat execution as autonomous except for pause, resume, stop, and goal update. +- Keep controller-owned machine state in TOON files. +- Leave the codebase in a maintainable state after each completed step. + diff --git a/.agent/controllers/controller-loop/state.toon b/.agent/controllers/controller-loop/state.toon new file mode 100644 index 0000000..37ce501 --- /dev/null +++ b/.agent/controllers/controller-loop/state.toon @@ -0,0 +1,17 @@ +version: 1 +phase: planning +goal_status: unknown +goal_revision: 0 +current_step_id: null +iteration: 0 +replan_required: false +completed_steps[0]: +blocked_steps[0]: +last_verification: null +last_cleanup_summary: null +last_full_test_summary: null +history[0]: +notes[0]: +planning_session: + pending_question: null + transcript[0]: diff --git a/.agent/controllers/keystone-seam-audit/goal.md b/.agent/controllers/keystone-seam-audit/goal.md new file mode 100644 index 0000000..8bc5325 --- /dev/null +++ b/.agent/controllers/keystone-seam-audit/goal.md @@ -0,0 +1,20 @@ +# Goal + +Identify oversized, hand-maintained source files in the repository, prioritize the highest-value refactor targets, and split them into smaller, cohesive modules without changing external behavior. + +A file should be considered a refactor candidate when it is materially large or overloaded, using these default signals: +- More than 300 lines of hand-written code. +- Multiple unrelated responsibilities in one file. +- Difficult-to-test logic mixed with I/O, UI, routing, state wiring, or formatting. + +Execution requirements: +- Ignore generated, vendored, build, cache, and lock files unless the repository clearly treats them as hand-maintained source. +- Refactor incrementally, one target at a time, starting with the largest safe candidate. +- Preserve public APIs and user-visible behavior unless a compatibility adjustment is required to complete the split safely. +- Leave the repository in a clean, test-passing state. + +Expected outputs: +- Smaller files with clearer ownership boundaries. +- Any necessary import/export or module wiring updates. +- Tests updated or added when needed to preserve behavior. +- A concise summary of files split, new module boundaries, and verification results. \ No newline at end of file diff --git a/.agent/controllers/keystone-seam-audit/plan.toon b/.agent/controllers/keystone-seam-audit/plan.toon new file mode 100644 index 0000000..1b11d6d --- /dev/null +++ b/.agent/controllers/keystone-seam-audit/plan.toon @@ -0,0 +1,94 @@ +version: 1 +goal_summary: "Audit the repository for oversized hand-maintained source files, prioritize safe high-value refactor targets, split them into smaller cohesive modules, and finish with passing validation and a clean diff." +steps[6]: + - id: s1 + title: Establish Safe Refactor Scope + purpose: "Load controller inputs and repository constraints, confirm the working tree state, and define the exact file-selection rules so execution can proceed without ambiguity." + inputs[4]: ".agent/controllers/keystone-seam-audit/goal.md",".agent/controllers/keystone-seam-audit/standards.md",repository working tree,"repo-level instructions such as AGENTS.md if present" + outputs[3]: confirmed execution constraints,candidate exclusion rules,list of locally modified files to avoid or handle carefully + dependencies[0]: + verification[2]: + - label: Check working tree state + commands[1]: "git status --short" + - label: Locate repo instructions + commands[1]: "rg --files -g 'AGENTS.md' -g '.agent/**'" + cleanup_requirements[1]{label,description}: + No accidental overlap with user edits,Do not modify files with unrelated local changes unless the change is required and the existing edits are understood and preserved. + status: active + attempts: 1 + - id: s2 + title: "Inventory Large Hand-Maintained Files" + purpose: "Produce a ranked inventory of oversized source files while excluding generated and third-party material." + inputs[2]: repository file list,selection thresholds from goal and standards + outputs[3]: ranked candidate list with line counts,"excluded-path list",initial top refactor targets + dependencies[1]: s1 + verification[2]: + - label: Enumerate tracked files + commands[1]: "rg --files" + - label: Rank large files by line count + commands[1]: "python - <<'PY'\nimport os, subprocess\nexclude = {'node_modules','dist','build','coverage','.git','.next','.svelte-kit','target','vendor'}\nfiles = subprocess.check_output(['rg','--files']).decode().splitlines()\nrows = []\nfor f in files:\n parts = set(f.split('/'))\n if parts & exclude:\n continue\n if os.path.splitext(f)[1] in {'.png','.jpg','.jpeg','.gif','.svg','.lock','.snap','.min.js','.map'}:\n continue\n try:\n with open(f,'r',encoding='utf-8') as fh:\n n = sum(1 for _ in fh)\n except Exception:\n continue\n if n > 300:\n rows.append((n,f))\nfor n,f in sorted(rows, reverse=True)[:50]:\n print(f'{n}\\t{f}')\nPY" + cleanup_requirements[1]{label,description}: + Discard false positives,"Remove generated files, migration dumps, fixtures, and machine-authored artifacts from the candidate list before choosing targets." + status: todo + attempts: 0 + - id: s3 + title: Choose Refactor Order And Boundaries + purpose: "Inspect the largest candidates, decide which files are safe and high-value to split first, and define intended module boundaries before editing." + inputs[3]: ranked candidate list,current file contents,existing module structure + outputs[3]: ordered target list,boundary notes for each target,"explicit non-goals for each refactor" + dependencies[1]: s2 + verification[2]: + - label: Inspect top candidates + commands[2]: "sed -n '1,220p' ","sed -n '221,440p' " + - label: Map exports and dependents + commands[1]: "rg -n \"from ['\\\"]|require\\(\" " + cleanup_requirements[1]{label,description}: + "Avoid cosmetic-only churn","Do not split files purely by line count; only proceed when coherent seams such as utilities, domain logic, adapters, routes, or components are identifiable." + status: todo + attempts: 0 + - id: s4 + title: Refactor First Target Incrementally + purpose: "Split the highest-priority candidate into smaller cohesive files while preserving behavior and keeping the change reviewable." + inputs[3]: first target file,boundary notes,repo conventions + outputs[3]: new smaller modules,updated imports/exports,"target-specific validation result" + dependencies[1]: s3 + verification[2]: + - label: Run targeted tests or checks + commands[1]: "" + - label: Confirm file size reduction + commands[1]: "wc -l " + cleanup_requirements[1]{label,description}: + Remove temporary seams,"Delete transitional helpers, dead exports, and unused imports created during the split before moving on." + status: todo + attempts: 0 + - id: s5 + title: "Repeat For Remaining High-Value Targets" + purpose: "Continue the same refactor pattern for additional oversized files until the main high-value targets are addressed or diminishing returns are reached." + inputs[2]: remaining ordered targets,lessons from first refactor + outputs[3]: additional split modules,updated dependency wiring,"per-target validation notes" + dependencies[1]: s4 + verification[2]: + - label: "Run per-target validation after each split" + commands[1]: "" + - label: Track remaining oversized files + commands[1]: "python - <<'PY'\nimport os, subprocess\nexclude = {'node_modules','dist','build','coverage','.git','.next','.svelte-kit','target','vendor'}\nfiles = subprocess.check_output(['rg','--files']).decode().splitlines()\nfor f in files:\n parts = set(f.split('/'))\n if parts & exclude:\n continue\n try:\n with open(f,'r',encoding='utf-8') as fh:\n n = sum(1 for _ in fh)\n except Exception:\n continue\n if n > 300:\n print(f'{n}\\t{f}')\nPY" + cleanup_requirements[1]{label,description}: + Stop at sensible boundary,"Do not keep splitting once modules are cohesive and maintainable; leave well-structured files intact even if they remain moderately large." + status: todo + attempts: 0 + - id: s6 + title: Run Full Validation And Final Cleanup + purpose: "Verify repository health, remove leftover refactor debris, and produce a concise execution summary for the controller result." + inputs[2]: all refactor changes,repository validation commands + outputs[3]: final passing validation results,cleaned diff,"summary of targets, seams, and tests" + dependencies[1]: s5 + verification[2]: + - label: Run broadest available validation + commands[3]: "","","" + - label: Check for leftover issues + commands[2]: "git diff --check","git status --short" + cleanup_requirements[2]{label,description}: + Leave clean refactor artifacts,"Remove unused files, stale exports, dead code, and temporary comments; ensure only intentional source changes remain." + Record outcome,"Summarize which files were split, the new module boundaries, any tests added or updated, and any remaining large files intentionally left unchanged." + status: todo + attempts: 0 \ No newline at end of file diff --git a/.agent/controllers/keystone-seam-audit/standards.md b/.agent/controllers/keystone-seam-audit/standards.md new file mode 100644 index 0000000..6c84948 --- /dev/null +++ b/.agent/controllers/keystone-seam-audit/standards.md @@ -0,0 +1,14 @@ +# Standards + +- Prefer cohesive modules with a single clear responsibility over line-count-only splitting. +- Preserve behavior first; do not mix feature work with the refactor. +- Avoid one-off abstractions. New modules must reflect real ownership boundaries that the codebase can keep using. +- Keep public interfaces stable unless a minimal coordinated update is required. +- Exclude generated, vendored, compiled, snapshot, fixture, and lock files from refactor targets unless explicitly treated as source by the repo. +- Before editing, check for existing local changes in candidate files and avoid overwriting user work. +- Refactor in small, reviewable increments so each target can be validated independently. +- Run the smallest relevant test or lint command after each target, then run the broadest available final validation before finishing. +- If no automated tests exist for a risky seam, add or strengthen focused tests before or during the refactor when practical. +- Keep names, module placement, and imports maintainable and consistent with existing repo conventions. +- Remove dead helpers, obsolete re-exports, and temporary scaffolding introduced during the split. +- Leave tests green. \ No newline at end of file diff --git a/.agent/controllers/keystone-seam-audit/state.toon b/.agent/controllers/keystone-seam-audit/state.toon new file mode 100644 index 0000000..7498c9f --- /dev/null +++ b/.agent/controllers/keystone-seam-audit/state.toon @@ -0,0 +1,25 @@ +version: 1 +phase: executing +goal_status: "in-progress" +goal_revision: 1 +current_step_id: null +iteration: 0 +replan_required: false +completed_steps[0]: +blocked_steps[0]: +last_verification: null +last_cleanup_summary: null +last_full_test_summary: null +history[0]: +notes[0]: +planning_session: + pending_question: null + transcript[4]{role,content}: + user,test + assistant,"What should the `keystone-seam-audit` controller actually do? Please specify the target system/repo area, what a \"seam audit\" should inspect, and the expected output artifact(s)." + user,find large files and refactor to smaller files + assistant,Planning completed +started_at: "1775273562" +last_usage_refresh_at: "1775274722" +last_usage_input_tokens: null +last_usage_output_tokens: null \ No newline at end of file diff --git a/.direnv/bin/nix-direnv-reload b/.direnv/bin/nix-direnv-reload new file mode 100755 index 0000000..9a2cff7 --- /dev/null +++ b/.direnv/bin/nix-direnv-reload @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e +if [[ ! -d "/Users/eric/Projects/nodeiwest/codex-controller-loop" ]]; then + echo "Cannot find source directory; Did you move it?" + echo "(Looking for "/Users/eric/Projects/nodeiwest/codex-controller-loop")" + echo 'Cannot force reload with this script - use "direnv reload" manually and then try again' + exit 1 +fi + +# rebuild the cache forcefully +_nix_direnv_force_reload=1 direnv exec "/Users/eric/Projects/nodeiwest/codex-controller-loop" true + +# Update the mtime for .envrc. +# This will cause direnv to reload again - but without re-building. +touch "/Users/eric/Projects/nodeiwest/codex-controller-loop/.envrc" + +# Also update the timestamp of whatever profile_rc we have. +# This makes sure that we know we are up to date. +touch -r "/Users/eric/Projects/nodeiwest/codex-controller-loop/.envrc" "/Users/eric/Projects/nodeiwest/codex-controller-loop/.direnv"/*.rc diff --git a/.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source b/.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source new file mode 120000 index 0000000..f7fce9f --- /dev/null +++ b/.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source @@ -0,0 +1 @@ +/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source \ No newline at end of file diff --git a/.direnv/flake-inputs/j8wb3r6xmck1kwx5yfhgl0dlg8y2qa1b-source b/.direnv/flake-inputs/j8wb3r6xmck1kwx5yfhgl0dlg8y2qa1b-source new file mode 120000 index 0000000..9346760 --- /dev/null +++ b/.direnv/flake-inputs/j8wb3r6xmck1kwx5yfhgl0dlg8y2qa1b-source @@ -0,0 +1 @@ +/nix/store/j8wb3r6xmck1kwx5yfhgl0dlg8y2qa1b-source \ No newline at end of file diff --git a/.direnv/flake-inputs/xaknai40sz4yyy5658prwrwmfycj4xvm-source b/.direnv/flake-inputs/xaknai40sz4yyy5658prwrwmfycj4xvm-source new file mode 120000 index 0000000..72e7534 --- /dev/null +++ b/.direnv/flake-inputs/xaknai40sz4yyy5658prwrwmfycj4xvm-source @@ -0,0 +1 @@ +/nix/store/xaknai40sz4yyy5658prwrwmfycj4xvm-source \ No newline at end of file diff --git a/.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source b/.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source new file mode 120000 index 0000000..f17959f --- /dev/null +++ b/.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source @@ -0,0 +1 @@ +/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source \ No newline at end of file diff --git a/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa new file mode 120000 index 0000000..4ba9f1e --- /dev/null +++ b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa @@ -0,0 +1 @@ +/nix/store/y0camphrdlb2higdygca6b3sqia1bgf6-nix-shell-env \ No newline at end of file diff --git a/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc new file mode 100644 index 0000000..839510b --- /dev/null +++ b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc @@ -0,0 +1,2156 @@ +unset shellHook +PATH=${PATH:-} +nix_saved_PATH="$PATH" +XDG_DATA_DIRS=${XDG_DATA_DIRS:-} +nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS" +AR='ar' +export AR +AS='as' +export AS +BASH='/nix/store/s0psayl7zvkvwdcqc8fy1sbv8rlf1yq8-bash-5.3p9/bin/bash' +CC='clang' +export CC +CONFIG_SHELL='/nix/store/s0psayl7zvkvwdcqc8fy1sbv8rlf1yq8-bash-5.3p9/bin/bash' +export CONFIG_SHELL +CXX='clang++' +export CXX +DEVELOPER_DIR='/nix/store/q2dccg26bm7bn6ia1q30qkl5jck7wwgb-apple-sdk-14.4' +export DEVELOPER_DIR +HOSTTYPE='aarch64' +HOST_PATH='/nix/store/a85h00app701vf0ggln0r97yayszvwkk-libiconv-109.100.2/bin:/nix/store/akih5l2yxpzqyh63xvyc6zsxl7kl2x4v-coreutils-9.10/bin:/nix/store/f9ik2jdvk6shdnzr4l8mibqdiqjd9chb-findutils-4.10.0/bin:/nix/store/hrkhhbx6v8fzwnizwvqh9h0yff5vcp75-diffutils-3.12/bin:/nix/store/m188brzrrd4f0jdiy495vz8pz75j5kpn-gnused-4.9/bin:/nix/store/mwj8nml055g8w0c2yq1apajcwrqgsg9q-gnugrep-3.12/bin:/nix/store/bvrbfzyimpjxwn679252bhbbccnb43nr-gawk-5.3.2/bin:/nix/store/k5q5hl5zhvvh9j2q9nr6wk8wx4f5rddv-gnutar-1.35/bin:/nix/store/fx6qvxgcvfpjhy5wr9hsvpkpjqw5zgf0-gzip-1.14/bin:/nix/store/4rqfrsyqp53wavnk86k4flm35vhagwjk-bzip2-1.0.8-bin/bin:/nix/store/y40n7jzzy9qydb120kxgbzi55mprbkfm-gnumake-4.4.1/bin:/nix/store/s0psayl7zvkvwdcqc8fy1sbv8rlf1yq8-bash-5.3p9/bin:/nix/store/jqcdmxsh8zk7bn1qcxwq3dwjjx4p7i0f-patch-2.8/bin:/nix/store/fc6w1zkl1klj979isgaag8a66jmpq1qs-xz-5.8.2-bin/bin:/nix/store/5n5ynvylwkjrblm2lpj3ns2qc821fvmi-file-5.45/bin' +export HOST_PATH +IFS=' +' +IN_NIX_SHELL='impure' +export IN_NIX_SHELL +LD='ld' +export LD +LD_DYLD_PATH='/usr/lib/dyld' +export LD_DYLD_PATH +LINENO='80' +MACHTYPE='aarch64-apple-darwin25.3.0' +MACOSX_DEPLOYMENT_TARGET='14.0' +export MACOSX_DEPLOYMENT_TARGET +NIX_APPLE_SDK_VERSION='140400' +export NIX_APPLE_SDK_VERSION +NIX_BINTOOLS='/nix/store/hwn7mviydmcdr2gw3zx30rcc0xi7k40c-cctools-binutils-darwin-wrapper-1010.6' +export NIX_BINTOOLS +NIX_BINTOOLS_WRAPPER_TARGET_HOST_arm64_apple_darwin='1' +export NIX_BINTOOLS_WRAPPER_TARGET_HOST_arm64_apple_darwin +NIX_BUILD_CORES='8' +export NIX_BUILD_CORES +NIX_CC='/nix/store/s7qlr26bmc6n4r607scz8iiwcg6yg4ic-clang-wrapper-21.1.8' +export NIX_CC +NIX_CC_WRAPPER_TARGET_HOST_arm64_apple_darwin='1' +export NIX_CC_WRAPPER_TARGET_HOST_arm64_apple_darwin +NIX_CFLAGS_COMPILE=' -frandom-seed=y0camphrdl -isystem /nix/store/gas29mwgqh0i3d4083ygl9b65sll1yil-libcxx-20.1.0+apple-sdk-26.0/include -fmacro-prefix-map=/nix/store/gas29mwgqh0i3d4083ygl9b65sll1yil-libcxx-20.1.0+apple-sdk-26.0=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-libcxx-20.1.0+apple-sdk-26.0 -isystem /nix/store/pnazzar06qzgcbsln5shjnmrq0krryww-compiler-rt-libc-21.1.8-dev/include -fmacro-prefix-map=/nix/store/pnazzar06qzgcbsln5shjnmrq0krryww-compiler-rt-libc-21.1.8-dev=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-compiler-rt-libc-21.1.8-dev -isystem /nix/store/4l8729yjln4zhnry763pklqb75dwmrd2-libiconv-109.100.2-dev/include -fmacro-prefix-map=/nix/store/4l8729yjln4zhnry763pklqb75dwmrd2-libiconv-109.100.2-dev=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-libiconv-109.100.2-dev -isystem /nix/store/d6dh7sdypw64lf74iv0gwgphvs1dq3fa-libresolv-91-dev/include -fmacro-prefix-map=/nix/store/d6dh7sdypw64lf74iv0gwgphvs1dq3fa-libresolv-91-dev=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-libresolv-91-dev -isystem /nix/store/ykwz6395vf4gn185zfvkx30avzmap11y-libsbuf-14.1.0-dev/include -fmacro-prefix-map=/nix/store/ykwz6395vf4gn185zfvkx30avzmap11y-libsbuf-14.1.0-dev=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-libsbuf-14.1.0-dev -isystem /nix/store/gas29mwgqh0i3d4083ygl9b65sll1yil-libcxx-20.1.0+apple-sdk-26.0/include -fmacro-prefix-map=/nix/store/gas29mwgqh0i3d4083ygl9b65sll1yil-libcxx-20.1.0+apple-sdk-26.0=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-libcxx-20.1.0+apple-sdk-26.0 -isystem /nix/store/pnazzar06qzgcbsln5shjnmrq0krryww-compiler-rt-libc-21.1.8-dev/include -fmacro-prefix-map=/nix/store/pnazzar06qzgcbsln5shjnmrq0krryww-compiler-rt-libc-21.1.8-dev=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-compiler-rt-libc-21.1.8-dev -isystem /nix/store/4l8729yjln4zhnry763pklqb75dwmrd2-libiconv-109.100.2-dev/include -fmacro-prefix-map=/nix/store/4l8729yjln4zhnry763pklqb75dwmrd2-libiconv-109.100.2-dev=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-libiconv-109.100.2-dev -isystem /nix/store/d6dh7sdypw64lf74iv0gwgphvs1dq3fa-libresolv-91-dev/include -fmacro-prefix-map=/nix/store/d6dh7sdypw64lf74iv0gwgphvs1dq3fa-libresolv-91-dev=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-libresolv-91-dev -isystem /nix/store/ykwz6395vf4gn185zfvkx30avzmap11y-libsbuf-14.1.0-dev/include -fmacro-prefix-map=/nix/store/ykwz6395vf4gn185zfvkx30avzmap11y-libsbuf-14.1.0-dev=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-libsbuf-14.1.0-dev' +export NIX_CFLAGS_COMPILE +NIX_DONT_SET_RPATH='1' +export NIX_DONT_SET_RPATH +NIX_DONT_SET_RPATH_FOR_BUILD='1' +export NIX_DONT_SET_RPATH_FOR_BUILD +NIX_ENFORCE_NO_NATIVE='1' +export NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningfast pic relro stackclashprotection stackprotector strictflexarrays1 strictoverflow zerocallusedregs' +export NIX_HARDENING_ENABLE +NIX_IGNORE_LD_THROUGH_GCC='1' +export NIX_IGNORE_LD_THROUGH_GCC +NIX_LDFLAGS=' -L/nix/store/gas29mwgqh0i3d4083ygl9b65sll1yil-libcxx-20.1.0+apple-sdk-26.0/lib -L/nix/store/hnb6cg3cfrsnhfppiaa6zzpk1i57wzm3-compiler-rt-libc-21.1.8/lib -L/nix/store/a85h00app701vf0ggln0r97yayszvwkk-libiconv-109.100.2/lib -L/nix/store/ymxg8vmmk5l8wvs5lld3vl9k3rhvdh59-libresolv-91/lib -L/nix/store/3kl23yrdmyy7c8m4fvzg2kilks9ca83s-libsbuf-14.1.0/lib -L/nix/store/22mhkcy5mpkgsa7k1d04qkxybnkwhqc4-libutil-72/lib -L/nix/store/gas29mwgqh0i3d4083ygl9b65sll1yil-libcxx-20.1.0+apple-sdk-26.0/lib -L/nix/store/hnb6cg3cfrsnhfppiaa6zzpk1i57wzm3-compiler-rt-libc-21.1.8/lib -L/nix/store/a85h00app701vf0ggln0r97yayszvwkk-libiconv-109.100.2/lib -L/nix/store/ymxg8vmmk5l8wvs5lld3vl9k3rhvdh59-libresolv-91/lib -L/nix/store/3kl23yrdmyy7c8m4fvzg2kilks9ca83s-libsbuf-14.1.0/lib -L/nix/store/22mhkcy5mpkgsa7k1d04qkxybnkwhqc4-libutil-72/lib' +export NIX_LDFLAGS +NIX_NO_SELF_RPATH='1' +export NIX_NO_SELF_RPATH +NIX_STORE='/nix/store' +export NIX_STORE +NM='nm' +export NM +OBJCOPY='objcopy' +export OBJCOPY +OBJDUMP='objdump' +export OBJDUMP +OLDPWD='' +export OLDPWD +OPTERR='1' +OSTYPE='darwin25.3.0' +PATH='/nix/store/6qbj40r0s289k5slmy8yna5x2hfz01wg-git-2.53.0/bin:/nix/store/0p9bi4b2dzlggz2irpnbvcf5rb6lcm9m-rustc-wrapper-1.94.0/bin:/nix/store/fp6mq617pb2rwrkc2fspjbwgf85jdb6n-cargo-1.94.0/bin:/nix/store/zhiy290rs2xwi9146nyz9w72q0sa9iq1-clippy-1.94.0/bin:/nix/store/fwgjpxfnwrbymfab8yd6hp9nws2b8jpn-rustfmt-1.94.0/bin:/nix/store/lnvhspzjms1qx8abfwr735h3cg3h265r-rust-analyzer-2026-03-23/bin:/nix/store/s7qlr26bmc6n4r607scz8iiwcg6yg4ic-clang-wrapper-21.1.8/bin:/nix/store/6adskryjj6g2p508xjxp2x4iwyy15gsr-clang-21.1.8/bin:/nix/store/akih5l2yxpzqyh63xvyc6zsxl7kl2x4v-coreutils-9.10/bin:/nix/store/hwn7mviydmcdr2gw3zx30rcc0xi7k40c-cctools-binutils-darwin-wrapper-1010.6/bin:/nix/store/yppcv1v8rdm3086ykynmf7w5yy61v30b-cctools-binutils-darwin-1010.6/bin:/nix/store/930py6p7h4xw7fwys7y3hx7rkyqnpvqq-xcbuild-0.1.1-unstable-2019-11-20-xcrun/bin:/nix/store/a85h00app701vf0ggln0r97yayszvwkk-libiconv-109.100.2/bin:/nix/store/akih5l2yxpzqyh63xvyc6zsxl7kl2x4v-coreutils-9.10/bin:/nix/store/f9ik2jdvk6shdnzr4l8mibqdiqjd9chb-findutils-4.10.0/bin:/nix/store/hrkhhbx6v8fzwnizwvqh9h0yff5vcp75-diffutils-3.12/bin:/nix/store/m188brzrrd4f0jdiy495vz8pz75j5kpn-gnused-4.9/bin:/nix/store/mwj8nml055g8w0c2yq1apajcwrqgsg9q-gnugrep-3.12/bin:/nix/store/bvrbfzyimpjxwn679252bhbbccnb43nr-gawk-5.3.2/bin:/nix/store/k5q5hl5zhvvh9j2q9nr6wk8wx4f5rddv-gnutar-1.35/bin:/nix/store/fx6qvxgcvfpjhy5wr9hsvpkpjqw5zgf0-gzip-1.14/bin:/nix/store/4rqfrsyqp53wavnk86k4flm35vhagwjk-bzip2-1.0.8-bin/bin:/nix/store/y40n7jzzy9qydb120kxgbzi55mprbkfm-gnumake-4.4.1/bin:/nix/store/s0psayl7zvkvwdcqc8fy1sbv8rlf1yq8-bash-5.3p9/bin:/nix/store/jqcdmxsh8zk7bn1qcxwq3dwjjx4p7i0f-patch-2.8/bin:/nix/store/fc6w1zkl1klj979isgaag8a66jmpq1qs-xz-5.8.2-bin/bin:/nix/store/5n5ynvylwkjrblm2lpj3ns2qc821fvmi-file-5.45/bin' +export PATH +PATH_LOCALE='/nix/store/r0di2qx1f6g3g2b4894ql8vyqd5h6q9h-locale-118/share/locale' +export PATH_LOCALE +PS4='+ ' +RANLIB='ranlib' +export RANLIB +SDKROOT='/nix/store/q2dccg26bm7bn6ia1q30qkl5jck7wwgb-apple-sdk-14.4/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk' +export SDKROOT +SHELL='/nix/store/s0psayl7zvkvwdcqc8fy1sbv8rlf1yq8-bash-5.3p9/bin/bash' +export SHELL +SIZE='size' +export SIZE +SOURCE_DATE_EPOCH='315532800' +export SOURCE_DATE_EPOCH +STRINGS='strings' +export STRINGS +STRIP='strip' +export STRIP +XDG_DATA_DIRS='/nix/store/6qbj40r0s289k5slmy8yna5x2hfz01wg-git-2.53.0/share:/nix/store/fp6mq617pb2rwrkc2fspjbwgf85jdb6n-cargo-1.94.0/share' +export XDG_DATA_DIRS +ZERO_AR_DATE='1' +export ZERO_AR_DATE +__darwinAllowLocalNetworking='' +export __darwinAllowLocalNetworking +__impureHostDeps='/bin/sh /usr/lib/libSystem.B.dylib /usr/lib/system/libunc.dylib /dev/zero /dev/random /dev/urandom /bin/sh' +export __impureHostDeps +__propagatedImpureHostDeps='' +export __propagatedImpureHostDeps +__propagatedSandboxProfile='' +export __propagatedSandboxProfile +__sandboxProfile='' +export __sandboxProfile +__structuredAttrs='' +export __structuredAttrs +_substituteStream_has_warned_replace_deprecation='false' +buildInputs='' +export buildInputs +buildPhase='{ echo "------------------------------------------------------------"; + echo " WARNING: the existence of this path is not guaranteed."; + echo " It is an internal implementation detail for pkgs.mkShell."; + echo "------------------------------------------------------------"; + echo; + # Record all build inputs as runtime dependencies + export; +} >> "$out" +' +export buildPhase +builder='/nix/store/s0psayl7zvkvwdcqc8fy1sbv8rlf1yq8-bash-5.3p9/bin/bash' +export builder +cmakeFlags='' +export cmakeFlags +configureFlags='' +export configureFlags +defaultBuildInputs='/nix/store/q2dccg26bm7bn6ia1q30qkl5jck7wwgb-apple-sdk-14.4' +defaultNativeBuildInputs='/nix/store/avjzyij6c5vbva2pqvfp7y9ch4aii05g-update-autotools-gnu-config-scripts-hook /nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh /nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh /nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh /nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh /nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh /nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh /nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh /nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh /nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh /nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh /nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh /nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh /nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh /nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh /nix/store/s7qlr26bmc6n4r607scz8iiwcg6yg4ic-clang-wrapper-21.1.8' +depsBuildBuild='' +export depsBuildBuild +depsBuildBuildPropagated='' +export depsBuildBuildPropagated +depsBuildTarget='' +export depsBuildTarget +depsBuildTargetPropagated='' +export depsBuildTargetPropagated +depsHostHost='' +export depsHostHost +depsHostHostPropagated='' +export depsHostHostPropagated +depsTargetTarget='' +export depsTargetTarget +depsTargetTargetPropagated='' +export depsTargetTargetPropagated +doCheck='' +export doCheck +doInstallCheck='' +export doInstallCheck +dontAddDisableDepTrack='1' +export dontAddDisableDepTrack +declare -a envBuildBuildHooks=() +declare -a envBuildHostHooks=() +declare -a envBuildTargetHooks=() +declare -a envHostHostHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' ) +declare -a envHostTargetHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' ) +declare -a envTargetTargetHooks=() +declare -a fixupOutputHooks=('if [[ -z "${noAuditTmpdir-}" && -e "$prefix" ]]; then auditTmpdir "$prefix"; fi' 'if [ -z "${dontGzipMan-}" ]; then compressManPages "$prefix"; fi' '_moveLib64' '_moveSbin' '_moveSystemdUserUnits' 'patchShebangsAuto' '_pruneLibtoolFiles' '_doStrip' ) +initialPath='/nix/store/akih5l2yxpzqyh63xvyc6zsxl7kl2x4v-coreutils-9.10 /nix/store/f9ik2jdvk6shdnzr4l8mibqdiqjd9chb-findutils-4.10.0 /nix/store/hrkhhbx6v8fzwnizwvqh9h0yff5vcp75-diffutils-3.12 /nix/store/m188brzrrd4f0jdiy495vz8pz75j5kpn-gnused-4.9 /nix/store/mwj8nml055g8w0c2yq1apajcwrqgsg9q-gnugrep-3.12 /nix/store/bvrbfzyimpjxwn679252bhbbccnb43nr-gawk-5.3.2 /nix/store/k5q5hl5zhvvh9j2q9nr6wk8wx4f5rddv-gnutar-1.35 /nix/store/fx6qvxgcvfpjhy5wr9hsvpkpjqw5zgf0-gzip-1.14 /nix/store/4rqfrsyqp53wavnk86k4flm35vhagwjk-bzip2-1.0.8-bin /nix/store/y40n7jzzy9qydb120kxgbzi55mprbkfm-gnumake-4.4.1 /nix/store/s0psayl7zvkvwdcqc8fy1sbv8rlf1yq8-bash-5.3p9 /nix/store/jqcdmxsh8zk7bn1qcxwq3dwjjx4p7i0f-patch-2.8 /nix/store/fc6w1zkl1klj979isgaag8a66jmpq1qs-xz-5.8.2-bin /nix/store/5n5ynvylwkjrblm2lpj3ns2qc821fvmi-file-5.45' +mesonFlags='' +export mesonFlags +name='nix-shell-env' +export name +nativeBuildInputs='/nix/store/6qbj40r0s289k5slmy8yna5x2hfz01wg-git-2.53.0 /nix/store/0p9bi4b2dzlggz2irpnbvcf5rb6lcm9m-rustc-wrapper-1.94.0 /nix/store/fp6mq617pb2rwrkc2fspjbwgf85jdb6n-cargo-1.94.0 /nix/store/zhiy290rs2xwi9146nyz9w72q0sa9iq1-clippy-1.94.0 /nix/store/fwgjpxfnwrbymfab8yd6hp9nws2b8jpn-rustfmt-1.94.0 /nix/store/lnvhspzjms1qx8abfwr735h3cg3h265r-rust-analyzer-2026-03-23' +export nativeBuildInputs +out='/Users/eric/Projects/nodeiwest/codex-controller-loop/outputs/out' +export out +outputBin='out' +outputDev='out' +outputDevdoc='REMOVE' +outputDevman='out' +outputDoc='out' +outputInclude='out' +outputInfo='out' +outputLib='out' +outputMan='out' +outputs='out' +export outputs +patches='' +export patches +phases='buildPhase' +export phases +pkg='/nix/store/q2dccg26bm7bn6ia1q30qkl5jck7wwgb-apple-sdk-14.4' +declare -a pkgsBuildBuild=() +declare -a pkgsBuildHost=('/nix/store/6qbj40r0s289k5slmy8yna5x2hfz01wg-git-2.53.0' '/nix/store/0p9bi4b2dzlggz2irpnbvcf5rb6lcm9m-rustc-wrapper-1.94.0' '/nix/store/fp6mq617pb2rwrkc2fspjbwgf85jdb6n-cargo-1.94.0' '/nix/store/zhiy290rs2xwi9146nyz9w72q0sa9iq1-clippy-1.94.0' '/nix/store/fwgjpxfnwrbymfab8yd6hp9nws2b8jpn-rustfmt-1.94.0' '/nix/store/lnvhspzjms1qx8abfwr735h3cg3h265r-rust-analyzer-2026-03-23' '/nix/store/avjzyij6c5vbva2pqvfp7y9ch4aii05g-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/s7qlr26bmc6n4r607scz8iiwcg6yg4ic-clang-wrapper-21.1.8' '/nix/store/hwn7mviydmcdr2gw3zx30rcc0xi7k40c-cctools-binutils-darwin-wrapper-1010.6' '/nix/store/930py6p7h4xw7fwys7y3hx7rkyqnpvqq-xcbuild-0.1.1-unstable-2019-11-20-xcrun' ) +declare -a pkgsBuildTarget=() +declare -a pkgsHostHost=('/nix/store/gas29mwgqh0i3d4083ygl9b65sll1yil-libcxx-20.1.0+apple-sdk-26.0' '/nix/store/pnazzar06qzgcbsln5shjnmrq0krryww-compiler-rt-libc-21.1.8-dev' '/nix/store/hnb6cg3cfrsnhfppiaa6zzpk1i57wzm3-compiler-rt-libc-21.1.8' ) +declare -a pkgsHostTarget=('/nix/store/q2dccg26bm7bn6ia1q30qkl5jck7wwgb-apple-sdk-14.4' '/nix/store/4l8729yjln4zhnry763pklqb75dwmrd2-libiconv-109.100.2-dev' '/nix/store/a85h00app701vf0ggln0r97yayszvwkk-libiconv-109.100.2' '/nix/store/d6dh7sdypw64lf74iv0gwgphvs1dq3fa-libresolv-91-dev' '/nix/store/ymxg8vmmk5l8wvs5lld3vl9k3rhvdh59-libresolv-91' '/nix/store/ykwz6395vf4gn185zfvkx30avzmap11y-libsbuf-14.1.0-dev' '/nix/store/3kl23yrdmyy7c8m4fvzg2kilks9ca83s-libsbuf-14.1.0' '/nix/store/22mhkcy5mpkgsa7k1d04qkxybnkwhqc4-libutil-72' ) +declare -a pkgsTargetTarget=() +declare -a postFixupHooks=('noBrokenSymlinksInAllOutputs' '_makeSymlinksRelative' '_multioutPropagateDev' ) +declare -a postUnpackHooks=('_updateSourceDateEpochFromSourceRoot' ) +declare -a preConfigureHooks=('_multioutConfig' ) +preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase' +declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' ) +preferLocalBuild='1' +export preferLocalBuild +prefix='/Users/eric/Projects/nodeiwest/codex-controller-loop/outputs/out' +declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' ) +propagatedBuildInputs='' +export propagatedBuildInputs +declare -a propagatedHostDepFiles=('propagated-host-host-deps' 'propagated-build-inputs' ) +propagatedNativeBuildInputs='' +export propagatedNativeBuildInputs +declare -a propagatedTargetDepFiles=('propagated-target-target-deps' ) +shell='/nix/store/s0psayl7zvkvwdcqc8fy1sbv8rlf1yq8-bash-5.3p9/bin/bash' +export shell +shellHook='' +export shellHook +stdenv='/nix/store/4c9ajc7qmxd8kaanj1c9v0fbi60bn805-stdenv-darwin' +export stdenv +strictDeps='' +export strictDeps +stripDebugFlags='-S' +system='aarch64-darwin' +export system +declare -a unpackCmdHooks=('_defaultUnpack' ) +_activatePkgs () +{ + + local hostOffset targetOffset; + local pkg; + for hostOffset in "${allPlatOffsets[@]}"; + do + local pkgsVar="${pkgAccumVarVars[hostOffset + 1]}"; + for targetOffset in "${allPlatOffsets[@]}"; + do + (( hostOffset <= targetOffset )) || continue; + local pkgsRef="${pkgsVar}[$targetOffset - $hostOffset]"; + local pkgsSlice="${!pkgsRef}[@]"; + for pkg in ${!pkgsSlice+"${!pkgsSlice}"}; + do + activatePackage "$pkg" "$hostOffset" "$targetOffset"; + done; + done; + done +} +_addRpathPrefix () +{ + + if [ "${NIX_NO_SELF_RPATH:-0}" != 1 ]; then + export NIX_LDFLAGS="-rpath $1/lib ${NIX_LDFLAGS-}"; + fi +} +_addToEnv () +{ + + local depHostOffset depTargetOffset; + local pkg; + for depHostOffset in "${allPlatOffsets[@]}"; + do + local hookVar="${pkgHookVarVars[depHostOffset + 1]}"; + local pkgsVar="${pkgAccumVarVars[depHostOffset + 1]}"; + for depTargetOffset in "${allPlatOffsets[@]}"; + do + (( depHostOffset <= depTargetOffset )) || continue; + local hookRef="${hookVar}[$depTargetOffset - $depHostOffset]"; + if [[ -z "${strictDeps-}" ]]; then + local visitedPkgs=""; + for pkg in "${pkgsBuildBuild[@]}" "${pkgsBuildHost[@]}" "${pkgsBuildTarget[@]}" "${pkgsHostHost[@]}" "${pkgsHostTarget[@]}" "${pkgsTargetTarget[@]}"; + do + if [[ "$visitedPkgs" = *"$pkg"* ]]; then + continue; + fi; + runHook "${!hookRef}" "$pkg"; + visitedPkgs+=" $pkg"; + done; + else + local pkgsRef="${pkgsVar}[$depTargetOffset - $depHostOffset]"; + local pkgsSlice="${!pkgsRef}[@]"; + for pkg in ${!pkgsSlice+"${!pkgsSlice}"}; + do + runHook "${!hookRef}" "$pkg"; + done; + fi; + done; + done +} +_allFlags () +{ + + export system pname name version; + while IFS='' read -r varName; do + nixTalkativeLog "@${varName}@ -> ${!varName}"; + args+=("--subst-var" "$varName"); + done < <(awk 'BEGIN { for (v in ENVIRON) if (v ~ /^[a-z][a-zA-Z0-9_]*$/) print v }') +} +_assignFirst () +{ + + local varName="$1"; + local _var; + local REMOVE=REMOVE; + shift; + for _var in "$@"; + do + if [ -n "${!_var-}" ]; then + eval "${varName}"="${_var}"; + return; + fi; + done; + echo; + echo "error: _assignFirst: could not find a non-empty variable whose name to assign to ${varName}."; + echo " The following variables were all unset or empty:"; + echo " $*"; + if [ -z "${out:-}" ]; then + echo ' If you do not want an "out" output in your derivation, make sure to define'; + echo ' the other specific required outputs. This can be achieved by picking one'; + echo " of the above as an output."; + echo ' You do not have to remove "out" if you want to have a different default'; + echo ' output, because the first output is taken as a default.'; + echo; + fi; + return 1 +} +_callImplicitHook () +{ + + local def="$1"; + local hookName="$2"; + if declare -F "$hookName" > /dev/null; then + nixTalkativeLog "calling implicit '$hookName' function hook"; + "$hookName"; + else + if type -p "$hookName" > /dev/null; then + nixTalkativeLog "sourcing implicit '$hookName' script hook"; + source "$hookName"; + else + if [ -n "${!hookName:-}" ]; then + nixTalkativeLog "evaling implicit '$hookName' string hook"; + eval "${!hookName}"; + else + return "$def"; + fi; + fi; + fi +} +_defaultUnpack () +{ + + local fn="$1"; + local destination; + if [ -d "$fn" ]; then + destination="$(stripHash "$fn")"; + if [ -e "$destination" ]; then + echo "Cannot copy $fn to $destination: destination already exists!"; + echo "Did you specify two \"srcs\" with the same \"name\"?"; + return 1; + fi; + cp -r --preserve=timestamps --reflink=auto -- "$fn" "$destination"; + else + case "$fn" in + *.tar.xz | *.tar.lzma | *.txz) + ( XZ_OPT="--threads=$NIX_BUILD_CORES" xz -d < "$fn"; + true ) | tar xf - --mode=+w --warning=no-timestamp + ;; + *.tar | *.tar.* | *.tgz | *.tbz2 | *.tbz) + tar xf "$fn" --mode=+w --warning=no-timestamp + ;; + *) + return 1 + ;; + esac; + fi +} +_doStrip () +{ + + local -ra flags=(dontStripHost dontStripTarget); + local -ra debugDirs=(stripDebugList stripDebugListTarget); + local -ra allDirs=(stripAllList stripAllListTarget); + local -ra stripCmds=(STRIP STRIP_FOR_TARGET); + local -ra ranlibCmds=(RANLIB RANLIB_FOR_TARGET); + stripDebugList=${stripDebugList[*]:-lib lib32 lib64 libexec bin sbin Applications Library/Frameworks}; + stripDebugListTarget=${stripDebugListTarget[*]:-}; + stripAllList=${stripAllList[*]:-}; + stripAllListTarget=${stripAllListTarget[*]:-}; + local i; + for i in ${!stripCmds[@]}; + do + local -n flag="${flags[$i]}"; + local -n debugDirList="${debugDirs[$i]}"; + local -n allDirList="${allDirs[$i]}"; + local -n stripCmd="${stripCmds[$i]}"; + local -n ranlibCmd="${ranlibCmds[$i]}"; + if [[ -n "${dontStrip-}" || -n "${flag-}" ]] || ! type -f "${stripCmd-}" 2> /dev/null 1>&2; then + continue; + fi; + stripDirs "$stripCmd" "$ranlibCmd" "$debugDirList" "${stripDebugFlags[*]:--S -p}"; + stripDirs "$stripCmd" "$ranlibCmd" "$allDirList" "${stripAllFlags[*]:--s -p}"; + done +} +_eval () +{ + + if declare -F "$1" > /dev/null 2>&1; then + "$@"; + else + eval "$1"; + fi +} +_logHook () +{ + + if [[ -z ${NIX_LOG_FD-} ]]; then + return; + fi; + local hookKind="$1"; + local hookExpr="$2"; + shift 2; + if declare -F "$hookExpr" > /dev/null 2>&1; then + nixTalkativeLog "calling '$hookKind' function hook '$hookExpr'" "$@"; + else + if type -p "$hookExpr" > /dev/null; then + nixTalkativeLog "sourcing '$hookKind' script hook '$hookExpr'"; + else + if [[ "$hookExpr" != "_callImplicitHook"* ]]; then + local exprToOutput; + if [[ ${NIX_DEBUG:-0} -ge 5 ]]; then + exprToOutput="$hookExpr"; + else + local hookExprLine; + while IFS= read -r hookExprLine; do + hookExprLine="${hookExprLine#"${hookExprLine%%[![:space:]]*}"}"; + if [[ -n "$hookExprLine" ]]; then + exprToOutput+="$hookExprLine\\n "; + fi; + done <<< "$hookExpr"; + exprToOutput="${exprToOutput%%\\n }"; + fi; + nixTalkativeLog "evaling '$hookKind' string hook '$exprToOutput'"; + fi; + fi; + fi +} +_makeSymlinksRelative () +{ + + local prefixes; + prefixes=(); + for output in $(getAllOutputNames); + do + [ ! -e "${!output}" ] && continue; + prefixes+=("${!output}"); + done; + find "${prefixes[@]}" -type l -printf '%H\0%p\0' | xargs -0 -n2 -r -P "$NIX_BUILD_CORES" sh -c ' + output="$1" + link="$2" + + linkTarget=$(readlink "$link") + + # only touch links that point inside the same output tree + [[ $linkTarget == "$output"/* ]] || exit 0 + + if [ ! -e "$linkTarget" ]; then + echo "the symlink $link is broken, it points to $linkTarget (which is missing)" + fi + + echo "making symlink relative: $link" + ln -snrf "$linkTarget" "$link" + ' _ +} +_moveLib64 () +{ + + if [ "${dontMoveLib64-}" = 1 ]; then + return; + fi; + if [ ! -e "$prefix/lib64" -o -L "$prefix/lib64" ]; then + return; + fi; + echo "moving $prefix/lib64/* to $prefix/lib"; + mkdir -p $prefix/lib; + shopt -s dotglob; + for i in $prefix/lib64/*; + do + mv --no-clobber "$i" $prefix/lib; + done; + shopt -u dotglob; + rmdir $prefix/lib64; + ln -s lib $prefix/lib64 +} +_moveSbin () +{ + + if [ "${dontMoveSbin-}" = 1 ]; then + return; + fi; + if [ ! -e "$prefix/sbin" -o -L "$prefix/sbin" ]; then + return; + fi; + echo "moving $prefix/sbin/* to $prefix/bin"; + mkdir -p $prefix/bin; + shopt -s dotglob; + for i in $prefix/sbin/*; + do + mv "$i" $prefix/bin; + done; + shopt -u dotglob; + rmdir $prefix/sbin; + ln -s bin $prefix/sbin +} +_moveSystemdUserUnits () +{ + + if [ "${dontMoveSystemdUserUnits:-0}" = 1 ]; then + return; + fi; + if [ ! -e "${prefix:?}/lib/systemd/user" ]; then + return; + fi; + local source="$prefix/lib/systemd/user"; + local target="$prefix/share/systemd/user"; + echo "moving $source/* to $target"; + mkdir -p "$target"; + ( shopt -s dotglob; + for i in "$source"/*; + do + mv "$i" "$target"; + done ); + rmdir "$source"; + ln -s "$target" "$source" +} +_moveToShare () +{ + + if [ -n "$__structuredAttrs" ]; then + if [ -z "${forceShare-}" ]; then + forceShare=(man doc info); + fi; + else + forceShare=(${forceShare:-man doc info}); + fi; + if [[ -z "$out" ]]; then + return; + fi; + for d in "${forceShare[@]}"; + do + if [ -d "$out/$d" ]; then + if [ -d "$out/share/$d" ]; then + echo "both $d/ and share/$d/ exist!"; + else + echo "moving $out/$d to $out/share/$d"; + mkdir -p $out/share; + mv $out/$d $out/share/; + fi; + fi; + done +} +_multioutConfig () +{ + + if [ "$(getAllOutputNames)" = "out" ] || [ -z "${setOutputFlags-1}" ]; then + return; + fi; + if [ -z "${shareDocName:-}" ]; then + local confScript="${configureScript:-}"; + if [ -z "$confScript" ] && [ -x ./configure ]; then + confScript=./configure; + fi; + if [ -f "$confScript" ]; then + local shareDocName="$(sed -n "s/^PACKAGE_TARNAME='\(.*\)'$/\1/p" < "$confScript")"; + fi; + if [ -z "$shareDocName" ] || echo "$shareDocName" | grep -q '[^a-zA-Z0-9_-]'; then + shareDocName="$(echo "$name" | sed 's/-[^a-zA-Z].*//')"; + fi; + fi; + prependToVar configureFlags --bindir="${!outputBin}"/bin --sbindir="${!outputBin}"/sbin --includedir="${!outputInclude}"/include --mandir="${!outputMan}"/share/man --infodir="${!outputInfo}"/share/info --docdir="${!outputDoc}"/share/doc/"${shareDocName}" --libdir="${!outputLib}"/lib --libexecdir="${!outputLib}"/libexec --localedir="${!outputLib}"/share/locale; + prependToVar installFlags pkgconfigdir="${!outputDev}"/lib/pkgconfig m4datadir="${!outputDev}"/share/aclocal aclocaldir="${!outputDev}"/share/aclocal +} +_multioutDevs () +{ + + if [ "$(getAllOutputNames)" = "out" ] || [ -z "${moveToDev-1}" ]; then + return; + fi; + moveToOutput include "${!outputInclude}"; + moveToOutput lib/pkgconfig "${!outputDev}"; + moveToOutput share/pkgconfig "${!outputDev}"; + moveToOutput lib/cmake "${!outputDev}"; + moveToOutput share/aclocal "${!outputDev}"; + for f in "${!outputDev}"/{lib,share}/pkgconfig/*.pc; + do + echo "Patching '$f' includedir to output ${!outputInclude}"; + sed -i "/^includedir=/s,=\${prefix},=${!outputInclude}," "$f"; + done +} +_multioutDocs () +{ + + local REMOVE=REMOVE; + moveToOutput share/info "${!outputInfo}"; + moveToOutput share/doc "${!outputDoc}"; + moveToOutput share/gtk-doc "${!outputDevdoc}"; + moveToOutput share/devhelp/books "${!outputDevdoc}"; + moveToOutput share/man "${!outputMan}"; + moveToOutput share/man/man3 "${!outputDevman}" +} +_multioutPropagateDev () +{ + + if [ "$(getAllOutputNames)" = "out" ]; then + return; + fi; + local outputFirst; + for outputFirst in $(getAllOutputNames); + do + break; + done; + local propagaterOutput="$outputDev"; + if [ -z "$propagaterOutput" ]; then + propagaterOutput="$outputFirst"; + fi; + if [ -z "${propagatedBuildOutputs+1}" ]; then + local po_dirty="$outputBin $outputInclude $outputLib"; + set +o pipefail; + propagatedBuildOutputs=`echo "$po_dirty" | tr -s ' ' '\n' | grep -v -F "$propagaterOutput" | sort -u | tr '\n' ' ' `; + set -o pipefail; + fi; + if [ -z "$propagatedBuildOutputs" ]; then + return; + fi; + mkdir -p "${!propagaterOutput}"/nix-support; + for output in $propagatedBuildOutputs; + do + echo -n " ${!output}" >> "${!propagaterOutput}"/nix-support/propagated-build-inputs; + done +} +_nixLogWithLevel () +{ + + [[ -z ${NIX_LOG_FD-} || ${NIX_DEBUG:-0} -lt ${1:?} ]] && return 0; + local logLevel; + case "${1:?}" in + 0) + logLevel=ERROR + ;; + 1) + logLevel=WARN + ;; + 2) + logLevel=NOTICE + ;; + 3) + logLevel=INFO + ;; + 4) + logLevel=TALKATIVE + ;; + 5) + logLevel=CHATTY + ;; + 6) + logLevel=DEBUG + ;; + 7) + logLevel=VOMIT + ;; + *) + echo "_nixLogWithLevel: called with invalid log level: ${1:?}" >&"$NIX_LOG_FD"; + return 1 + ;; + esac; + local callerName="${FUNCNAME[2]}"; + if [[ $callerName == "_callImplicitHook" ]]; then + callerName="${hookName:?}"; + fi; + printf "%s: %s: %s\n" "$logLevel" "$callerName" "${2:?}" >&"$NIX_LOG_FD" +} +_overrideFirst () +{ + + if [ -z "${!1-}" ]; then + _assignFirst "$@"; + fi +} +_pruneLibtoolFiles () +{ + + if [ "${dontPruneLibtoolFiles-}" ] || [ ! -e "$prefix" ]; then + return; + fi; + find "$prefix" -type f -name '*.la' -exec grep -q '^# Generated by .*libtool' {} \; -exec grep -q "^old_library=''" {} \; -exec sed -i {} -e "/^dependency_libs='[^']/ c dependency_libs='' #pruned" \; +} +_updateSourceDateEpochFromSourceRoot () +{ + + if [ -n "$sourceRoot" ]; then + updateSourceDateEpoch "$sourceRoot"; + fi +} +activatePackage () +{ + + local pkg="$1"; + local -r hostOffset="$2"; + local -r targetOffset="$3"; + (( hostOffset <= targetOffset )) || exit 1; + if [ -f "$pkg" ]; then + nixTalkativeLog "sourcing setup hook '$pkg'"; + source "$pkg"; + fi; + if [[ -z "${strictDeps-}" || "$hostOffset" -le -1 ]]; then + addToSearchPath _PATH "$pkg/bin"; + fi; + if (( hostOffset <= -1 )); then + addToSearchPath _XDG_DATA_DIRS "$pkg/share"; + fi; + if [[ "$hostOffset" -eq 0 && -d "$pkg/bin" ]]; then + addToSearchPath _HOST_PATH "$pkg/bin"; + fi; + if [[ -f "$pkg/nix-support/setup-hook" ]]; then + nixTalkativeLog "sourcing setup hook '$pkg/nix-support/setup-hook'"; + source "$pkg/nix-support/setup-hook"; + fi +} +addEnvHooks () +{ + + local depHostOffset="$1"; + shift; + local pkgHookVarsSlice="${pkgHookVarVars[$depHostOffset + 1]}[@]"; + local pkgHookVar; + for pkgHookVar in "${!pkgHookVarsSlice}"; + do + eval "${pkgHookVar}s"'+=("$@")'; + done +} +addToSearchPath () +{ + + addToSearchPathWithCustomDelimiter ":" "$@" +} +addToSearchPathWithCustomDelimiter () +{ + + local delimiter="$1"; + local varName="$2"; + local dir="$3"; + if [[ -d "$dir" && "${!varName:+${delimiter}${!varName}${delimiter}}" != *"${delimiter}${dir}${delimiter}"* ]]; then + export "${varName}=${!varName:+${!varName}${delimiter}}${dir}"; + fi +} +appendToVar () +{ + + local -n nameref="$1"; + local useArray type; + if [ -n "$__structuredAttrs" ]; then + useArray=true; + else + useArray=false; + fi; + if type=$(declare -p "$1" 2> /dev/null); then + case "${type#* }" in + -A*) + echo "appendToVar(): ERROR: trying to use appendToVar on an associative array, use variable+=([\"X\"]=\"Y\") instead." 1>&2; + return 1 + ;; + -a*) + useArray=true + ;; + *) + useArray=false + ;; + esac; + fi; + shift; + if $useArray; then + nameref=(${nameref+"${nameref[@]}"} "$@"); + else + nameref="${nameref-} $*"; + fi +} +auditTmpdir () +{ + + local dir="$1"; + [ -e "$dir" ] || return 0; + echo "checking for references to $TMPDIR/ in $dir..."; + local tmpdir elf_fifo script_fifo; + tmpdir="$(mktemp -d)"; + elf_fifo="$tmpdir/elf"; + script_fifo="$tmpdir/script"; + mkfifo "$elf_fifo" "$script_fifo"; + ( find "$dir" -type f -not -path '*/.build-id/*' -print0 | while IFS= read -r -d '' file; do + if isELF "$file"; then + printf '%s\0' "$file" 1>&3; + else + if isScript "$file"; then + filename=${file##*/}; + dir=${file%/*}; + if [ -e "$dir/.$filename-wrapped" ]; then + printf '%s\0' "$file" 1>&4; + fi; + fi; + fi; + done; + exec 3>&- 4>&- ) 3> "$elf_fifo" 4> "$script_fifo" & ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c ' + if { printf :; patchelf --print-rpath "$1"; } | grep -q -F ":$TMPDIR/"; then + echo "RPATH of binary $1 contains a forbidden reference to $TMPDIR/" + exit 1 + fi + ' _ < "$elf_fifo" ) & local pid_elf=$!; + local pid_script; + ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c ' + if grep -q -F "$TMPDIR/" "$1"; then + echo "wrapper script $1 contains a forbidden reference to $TMPDIR/" + exit 1 + fi + ' _ < "$script_fifo" ) & local pid_script=$!; + wait "$pid_elf" || { + echo "Some binaries contain forbidden references to $TMPDIR/. Check the error above!"; + exit 1 + }; + wait "$pid_script" || { + echo "Some scripts contain forbidden references to $TMPDIR/. Check the error above!"; + exit 1 + }; + rm -r "$tmpdir" +} +bintoolsWrapper_addLDVars () +{ + + local role_post; + getHostRoleEnvHook; + if [[ -d "$1/lib64" && ! -L "$1/lib64" ]]; then + export NIX_LDFLAGS${role_post}+=" -L$1/lib64"; + fi; + if [[ -d "$1/lib" ]]; then + local -a glob=($1/lib/lib*); + if [ "${#glob[*]}" -gt 0 ]; then + export NIX_LDFLAGS${role_post}+=" -L$1/lib"; + fi; + fi +} +buildPhase () +{ + + runHook preBuild; + if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then + echo "no Makefile or custom buildPhase, doing nothing"; + else + foundMakefile=1; + local flagsArray=(${enableParallelBuilding:+-j${NIX_BUILD_CORES}} SHELL="$SHELL"); + concatTo flagsArray makeFlags makeFlagsArray buildFlags buildFlagsArray; + echoCmd 'build flags' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + unset flagsArray; + fi; + runHook postBuild +} +ccWrapper_addCVars () +{ + + local role_post; + getHostRoleEnvHook; + local found=; + if [ -d "$1/include" ]; then + export NIX_CFLAGS_COMPILE${role_post}+=" -isystem $1/include"; + found=1; + fi; + if [ -d "$1/Library/Frameworks" ]; then + export NIX_CFLAGS_COMPILE${role_post}+=" -iframework $1/Library/Frameworks"; + found=1; + fi; + if [[ -n "1" && -n ${NIX_STORE:-} && -n $found ]]; then + local scrubbed="$NIX_STORE/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-${1#"$NIX_STORE"/*-}"; + export NIX_CFLAGS_COMPILE${role_post}+=" -fmacro-prefix-map=$1=$scrubbed"; + fi +} +checkPhase () +{ + + runHook preCheck; + if [[ -z "${foundMakefile:-}" ]]; then + echo "no Makefile or custom checkPhase, doing nothing"; + runHook postCheck; + return; + fi; + if [[ -z "${checkTarget:-}" ]]; then + if make -n ${makefile:+-f $makefile} check > /dev/null 2>&1; then + checkTarget="check"; + else + if make -n ${makefile:+-f $makefile} test > /dev/null 2>&1; then + checkTarget="test"; + fi; + fi; + fi; + if [[ -z "${checkTarget:-}" ]]; then + echo "no check/test target in ${makefile:-Makefile}, doing nothing"; + else + local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL"); + concatTo flagsArray makeFlags makeFlagsArray checkFlags=VERBOSE=y checkFlagsArray checkTarget; + echoCmd 'check flags' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + unset flagsArray; + fi; + runHook postCheck +} +compressManPages () +{ + + local dir="$1"; + if [ -L "$dir"/share ] || [ -L "$dir"/share/man ] || [ ! -d "$dir/share/man" ]; then + return; + fi; + echo "gzipping man pages under $dir/share/man/"; + find "$dir"/share/man/ -type f -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | xargs -0 -n1 -P "$NIX_BUILD_CORES" gzip -n -f; + find "$dir"/share/man/ -type l -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | sort -z | while IFS= read -r -d '' f; do + local target; + target="$(readlink -f "$f")"; + if [ -f "$target".gz ]; then + ln -sf "$target".gz "$f".gz && rm "$f"; + fi; + done +} +concatStringsSep () +{ + + local sep="$1"; + local name="$2"; + local type oldifs; + if type=$(declare -p "$name" 2> /dev/null); then + local -n nameref="$name"; + case "${type#* }" in + -A*) + echo "concatStringsSep(): ERROR: trying to use concatStringsSep on an associative array." 1>&2; + return 1 + ;; + -a*) + local IFS="$(printf '\036')" + ;; + *) + local IFS=" " + ;; + esac; + local ifs_separated="${nameref[*]}"; + echo -n "${ifs_separated//"$IFS"/"$sep"}"; + fi +} +concatTo () +{ + + local -; + set -o noglob; + local -n targetref="$1"; + shift; + local arg default name type; + for arg in "$@"; + do + IFS="=" read -r name default <<< "$arg"; + local -n nameref="$name"; + if [[ -z "${nameref[*]}" && -n "$default" ]]; then + targetref+=("$default"); + else + if type=$(declare -p "$name" 2> /dev/null); then + case "${type#* }" in + -A*) + echo "concatTo(): ERROR: trying to use concatTo on an associative array." 1>&2; + return 1 + ;; + -a*) + targetref+=("${nameref[@]}") + ;; + *) + if [[ "$name" = *"Array" ]]; then + nixErrorLog "concatTo(): $name is not declared as array, treating as a singleton. This will become an error in future"; + targetref+=(${nameref+"${nameref[@]}"}); + else + targetref+=(${nameref-}); + fi + ;; + esac; + fi; + fi; + done +} +configurePhase () +{ + + runHook preConfigure; + : "${configureScript=}"; + if [[ -z "$configureScript" && -x ./configure ]]; then + configureScript=./configure; + fi; + if [ -z "${dontFixLibtool:-}" ]; then + export lt_cv_deplibs_check_method="${lt_cv_deplibs_check_method-pass_all}"; + local i; + find . -iname "ltmain.sh" -print0 | while IFS='' read -r -d '' i; do + echo "fixing libtool script $i"; + fixLibtool "$i"; + done; + CONFIGURE_MTIME_REFERENCE=$(mktemp configure.mtime.reference.XXXXXX); + find . -executable -type f -name configure -exec grep -l 'GNU Libtool is free software; you can redistribute it and/or modify' {} \; -exec touch -r {} "$CONFIGURE_MTIME_REFERENCE" \; -exec sed -i s_/usr/bin/file_file_g {} \; -exec touch -r "$CONFIGURE_MTIME_REFERENCE" {} \;; + rm -f "$CONFIGURE_MTIME_REFERENCE"; + fi; + if [[ -z "${dontAddPrefix:-}" && -n "$prefix" ]]; then + local -r prefixKeyOrDefault="${prefixKey:---prefix=}"; + if [ "${prefixKeyOrDefault: -1}" = " " ]; then + prependToVar configureFlags "$prefix"; + prependToVar configureFlags "${prefixKeyOrDefault::-1}"; + else + prependToVar configureFlags "$prefixKeyOrDefault$prefix"; + fi; + fi; + if [[ -f "$configureScript" ]]; then + if [ -z "${dontAddDisableDepTrack:-}" ]; then + if grep -q dependency-tracking "$configureScript"; then + prependToVar configureFlags --disable-dependency-tracking; + fi; + fi; + if [ -z "${dontDisableStatic:-}" ]; then + if grep -q enable-static "$configureScript"; then + prependToVar configureFlags --disable-static; + fi; + fi; + if [ -z "${dontPatchShebangsInConfigure:-}" ]; then + patchShebangs --build "$configureScript"; + fi; + fi; + if [ -n "$configureScript" ]; then + local -a flagsArray; + concatTo flagsArray configureFlags configureFlagsArray; + echoCmd 'configure flags' "${flagsArray[@]}"; + $configureScript "${flagsArray[@]}"; + unset flagsArray; + else + echo "no configure script, doing nothing"; + fi; + runHook postConfigure +} +consumeEntire () +{ + + if IFS='' read -r -d '' "$1"; then + echo "consumeEntire(): ERROR: Input null bytes, won't process" 1>&2; + return 1; + fi +} +definePhases () +{ + + if [ -z "${phases[*]:-}" ]; then + phases="${prePhases[*]:-} unpackPhase patchPhase ${preConfigurePhases[*]:-} configurePhase ${preBuildPhases[*]:-} buildPhase checkPhase ${preInstallPhases[*]:-} installPhase ${preFixupPhases[*]:-} fixupPhase installCheckPhase ${preDistPhases[*]:-} distPhase ${postPhases[*]:-}"; + fi +} +distPhase () +{ + + runHook preDist; + local flagsArray=(); + concatTo flagsArray distFlags distFlagsArray distTarget=dist; + echo 'dist flags: %q' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + if [ "${dontCopyDist:-0}" != 1 ]; then + mkdir -p "$out/tarballs"; + cp -pvd ${tarballs[*]:-*.tar.gz} "$out/tarballs"; + fi; + runHook postDist +} +dumpVars () +{ + + if [[ "${noDumpEnvVars:-0}" != 1 && -d "$NIX_BUILD_TOP" ]]; then + local old_umask; + old_umask=$(umask); + umask 0077; + export 2> /dev/null > "$NIX_BUILD_TOP/env-vars"; + umask "$old_umask"; + fi +} +echoCmd () +{ + + printf "%s:" "$1"; + shift; + printf ' %q' "$@"; + echo +} +exitHandler () +{ + + exitCode="$?"; + set +e; + if [ -n "${showBuildStats:-}" ]; then + read -r -d '' -a buildTimes < <(times); + echo "build times:"; + echo "user time for the shell ${buildTimes[0]}"; + echo "system time for the shell ${buildTimes[1]}"; + echo "user time for all child processes ${buildTimes[2]}"; + echo "system time for all child processes ${buildTimes[3]}"; + fi; + if (( "$exitCode" != 0 )); then + runHook failureHook; + if [ -n "${succeedOnFailure:-}" ]; then + echo "build failed with exit code $exitCode (ignored)"; + mkdir -p "$out/nix-support"; + printf "%s" "$exitCode" > "$out/nix-support/failed"; + exit 0; + fi; + else + runHook exitHook; + fi; + return "$exitCode" +} +findInputs () +{ + + local -r pkg="$1"; + local -r hostOffset="$2"; + local -r targetOffset="$3"; + (( hostOffset <= targetOffset )) || exit 1; + local varVar="${pkgAccumVarVars[hostOffset + 1]}"; + local varRef="$varVar[$((targetOffset - hostOffset))]"; + local var="${!varRef}"; + unset -v varVar varRef; + local varSlice="$var[*]"; + case " ${!varSlice-} " in + *" $pkg "*) + return 0 + ;; + esac; + unset -v varSlice; + eval "$var"'+=("$pkg")'; + if ! [ -e "$pkg" ]; then + echo "build input $pkg does not exist" 1>&2; + exit 1; + fi; + function mapOffset () + { + local -r inputOffset="$1"; + local -n outputOffset="$2"; + if (( inputOffset <= 0 )); then + outputOffset=$((inputOffset + hostOffset)); + else + outputOffset=$((inputOffset - 1 + targetOffset)); + fi + }; + local relHostOffset; + for relHostOffset in "${allPlatOffsets[@]}"; + do + local files="${propagatedDepFilesVars[relHostOffset + 1]}"; + local hostOffsetNext; + mapOffset "$relHostOffset" hostOffsetNext; + (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue; + local relTargetOffset; + for relTargetOffset in "${allPlatOffsets[@]}"; + do + (( "$relHostOffset" <= "$relTargetOffset" )) || continue; + local fileRef="${files}[$relTargetOffset - $relHostOffset]"; + local file="${!fileRef}"; + unset -v fileRef; + local targetOffsetNext; + mapOffset "$relTargetOffset" targetOffsetNext; + (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue; + [[ -f "$pkg/nix-support/$file" ]] || continue; + local pkgNext; + read -r -d '' pkgNext < "$pkg/nix-support/$file" || true; + for pkgNext in $pkgNext; + do + findInputs "$pkgNext" "$hostOffsetNext" "$targetOffsetNext"; + done; + done; + done +} +fixLibtool () +{ + + local search_path; + for flag in $NIX_LDFLAGS; + do + case $flag in + -L*) + search_path+=" ${flag#-L}" + ;; + esac; + done; + sed -i "$1" -e "s^eval \(sys_lib_search_path=\).*^\1'${search_path:-}'^" -e 's^eval sys_lib_.+search_path=.*^^' +} +fixupPhase () +{ + + local output; + for output in $(getAllOutputNames); + do + if [ -e "${!output}" ]; then + chmod -R u+w,u-s,g-s "${!output}"; + fi; + done; + runHook preFixup; + local output; + for output in $(getAllOutputNames); + do + prefix="${!output}" runHook fixupOutput; + done; + recordPropagatedDependencies; + if [ -n "${setupHook:-}" ]; then + mkdir -p "${!outputDev}/nix-support"; + substituteAll "$setupHook" "${!outputDev}/nix-support/setup-hook"; + fi; + if [ -n "${setupHooks:-}" ]; then + mkdir -p "${!outputDev}/nix-support"; + local hook; + for hook in ${setupHooks[@]}; + do + local content; + consumeEntire content < "$hook"; + substituteAllStream content "file '$hook'" >> "${!outputDev}/nix-support/setup-hook"; + unset -v content; + done; + unset -v hook; + fi; + if [ -n "${propagatedUserEnvPkgs[*]:-}" ]; then + mkdir -p "${!outputBin}/nix-support"; + printWords "${propagatedUserEnvPkgs[@]}" > "${!outputBin}/nix-support/propagated-user-env-packages"; + fi; + runHook postFixup +} +genericBuild () +{ + + export GZIP_NO_TIMESTAMPS=1; + if [ -f "${buildCommandPath:-}" ]; then + source "$buildCommandPath"; + return; + fi; + if [ -n "${buildCommand:-}" ]; then + eval "$buildCommand"; + return; + fi; + definePhases; + for curPhase in ${phases[*]}; + do + runPhase "$curPhase"; + done +} +getAllOutputNames () +{ + + if [ -n "$__structuredAttrs" ]; then + echo "${!outputs[*]}"; + else + echo "$outputs"; + fi +} +getHostRole () +{ + + getRole "$hostOffset" +} +getHostRoleEnvHook () +{ + + getRole "$depHostOffset" +} +getRole () +{ + + case $1 in + -1) + role_post='_FOR_BUILD' + ;; + 0) + role_post='' + ;; + 1) + role_post='_FOR_TARGET' + ;; + *) + echo "apple-sdk-14.4: used as improper sort of dependency" 1>&2; + return 1 + ;; + esac +} +getTargetRole () +{ + + getRole "$targetOffset" +} +getTargetRoleEnvHook () +{ + + getRole "$depTargetOffset" +} +getTargetRoleWrapper () +{ + + case $targetOffset in + -1) + export NIX_@wrapperName@_TARGET_BUILD_@suffixSalt@=1 + ;; + 0) + export NIX_@wrapperName@_TARGET_HOST_@suffixSalt@=1 + ;; + 1) + export NIX_@wrapperName@_TARGET_TARGET_@suffixSalt@=1 + ;; + *) + echo "apple-sdk-14.4: used as improper sort of dependency" 1>&2; + return 1 + ;; + esac +} +installCheckPhase () +{ + + runHook preInstallCheck; + if [[ -z "${foundMakefile:-}" ]]; then + echo "no Makefile or custom installCheckPhase, doing nothing"; + else + if [[ -z "${installCheckTarget:-}" ]] && ! make -n ${makefile:+-f $makefile} "${installCheckTarget:-installcheck}" > /dev/null 2>&1; then + echo "no installcheck target in ${makefile:-Makefile}, doing nothing"; + else + local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL"); + concatTo flagsArray makeFlags makeFlagsArray installCheckFlags installCheckFlagsArray installCheckTarget=installcheck; + echoCmd 'installcheck flags' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + unset flagsArray; + fi; + fi; + runHook postInstallCheck +} +installPhase () +{ + + runHook preInstall; + if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then + echo "no Makefile or custom installPhase, doing nothing"; + runHook postInstall; + return; + else + foundMakefile=1; + fi; + if [ -n "$prefix" ]; then + mkdir -p "$prefix"; + fi; + local flagsArray=(${enableParallelInstalling:+-j${NIX_BUILD_CORES}} SHELL="$SHELL"); + concatTo flagsArray makeFlags makeFlagsArray installFlags installFlagsArray installTargets=install; + echoCmd 'install flags' "${flagsArray[@]}"; + make ${makefile:+-f $makefile} "${flagsArray[@]}"; + unset flagsArray; + runHook postInstall +} +isELF () +{ + + local fn="$1"; + local fd; + local magic; + exec {fd}< "$fn"; + LANG=C read -r -n 4 -u "$fd" magic; + exec {fd}>&-; + if [ "$magic" = 'ELF' ]; then + return 0; + else + return 1; + fi +} +isMachO () +{ + + local fn="$1"; + local fd; + local magic; + exec {fd}< "$fn"; + LANG=C read -r -n 4 -u "$fd" magic; + exec {fd}>&-; + if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xcf") || "$magic" = $(echo -ne "\xcf\xfa\xed\xfe") ]]; then + return 0; + else + if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xce") || "$magic" = $(echo -ne "\xce\xfa\xed\xfe") ]]; then + return 0; + else + if [[ "$magic" = $(echo -ne "\xca\xfe\xba\xbe") || "$magic" = $(echo -ne "\xbe\xba\xfe\xca") ]]; then + return 0; + else + return 1; + fi; + fi; + fi +} +isScript () +{ + + local fn="$1"; + local fd; + local magic; + exec {fd}< "$fn"; + LANG=C read -r -n 2 -u "$fd" magic; + exec {fd}>&-; + if [[ "$magic" =~ \#! ]]; then + return 0; + else + return 1; + fi +} +mapOffset () +{ + + local -r inputOffset="$1"; + local -n outputOffset="$2"; + if (( inputOffset <= 0 )); then + outputOffset=$((inputOffset + hostOffset)); + else + outputOffset=$((inputOffset - 1 + targetOffset)); + fi +} +moveToOutput () +{ + + local patt="$1"; + local dstOut="$2"; + local output; + for output in $(getAllOutputNames); + do + if [ "${!output}" = "$dstOut" ]; then + continue; + fi; + local srcPath; + for srcPath in "${!output}"/$patt; + do + if [ ! -e "$srcPath" ] && [ ! -L "$srcPath" ]; then + continue; + fi; + if [ "$dstOut" = REMOVE ]; then + echo "Removing $srcPath"; + rm -r "$srcPath"; + else + local dstPath="$dstOut${srcPath#${!output}}"; + echo "Moving $srcPath to $dstPath"; + if [ -d "$dstPath" ] && [ -d "$srcPath" ]; then + rmdir "$srcPath" --ignore-fail-on-non-empty; + if [ -d "$srcPath" ]; then + mv -t "$dstPath" "$srcPath"/*; + rmdir "$srcPath"; + fi; + else + mkdir -p "$(readlink -m "$dstPath/..")"; + mv "$srcPath" "$dstPath"; + fi; + fi; + local srcParent="$(readlink -m "$srcPath/..")"; + if [ -n "$(find "$srcParent" -maxdepth 0 -type d -empty 2> /dev/null)" ]; then + echo "Removing empty $srcParent/ and (possibly) its parents"; + rmdir -p --ignore-fail-on-non-empty "$srcParent" 2> /dev/null || true; + fi; + done; + done +} +nixChattyLog () +{ + + _nixLogWithLevel 5 "$*" +} +nixDebugLog () +{ + + _nixLogWithLevel 6 "$*" +} +nixErrorLog () +{ + + _nixLogWithLevel 0 "$*" +} +nixInfoLog () +{ + + _nixLogWithLevel 3 "$*" +} +nixLog () +{ + + [[ -z ${NIX_LOG_FD-} ]] && return 0; + local callerName="${FUNCNAME[1]}"; + if [[ $callerName == "_callImplicitHook" ]]; then + callerName="${hookName:?}"; + fi; + printf "%s: %s\n" "$callerName" "$*" >&"$NIX_LOG_FD" +} +nixNoticeLog () +{ + + _nixLogWithLevel 2 "$*" +} +nixTalkativeLog () +{ + + _nixLogWithLevel 4 "$*" +} +nixVomitLog () +{ + + _nixLogWithLevel 7 "$*" +} +nixWarnLog () +{ + + _nixLogWithLevel 1 "$*" +} +noBrokenSymlinks () +{ + + local -r output="${1:?}"; + local path; + local pathParent; + local symlinkTarget; + local -i numDanglingSymlinks=0; + local -i numReflexiveSymlinks=0; + local -i numUnreadableSymlinks=0; + if [[ ! -e $output ]]; then + nixWarnLog "skipping non-existent output $output"; + return 0; + fi; + nixInfoLog "running on $output"; + while IFS= read -r -d '' path; do + pathParent="$(dirname "$path")"; + if ! symlinkTarget="$(readlink "$path")"; then + nixErrorLog "the symlink $path is unreadable"; + numUnreadableSymlinks+=1; + continue; + fi; + if [[ $symlinkTarget == /* ]]; then + nixInfoLog "symlink $path points to absolute target $symlinkTarget"; + else + nixInfoLog "symlink $path points to relative target $symlinkTarget"; + symlinkTarget="$(realpath --no-symlinks --canonicalize-missing "$pathParent/$symlinkTarget")"; + fi; + if [[ $symlinkTarget = "$TMPDIR"/* ]]; then + nixErrorLog "the symlink $path points to $TMPDIR directory: $symlinkTarget"; + numDanglingSymlinks+=1; + continue; + fi; + if [[ $symlinkTarget != "$NIX_STORE"/* ]]; then + nixInfoLog "symlink $path points outside the Nix store; ignoring"; + continue; + fi; + if [[ $path == "$symlinkTarget" ]]; then + nixErrorLog "the symlink $path is reflexive"; + numReflexiveSymlinks+=1; + else + if [[ ! -e $symlinkTarget ]]; then + nixErrorLog "the symlink $path points to a missing target: $symlinkTarget"; + numDanglingSymlinks+=1; + else + nixDebugLog "the symlink $path is irreflexive and points to a target which exists"; + fi; + fi; + done < <(find "$output" -type l -print0); + if ((numDanglingSymlinks > 0 || numReflexiveSymlinks > 0 || numUnreadableSymlinks > 0)); then + nixErrorLog "found $numDanglingSymlinks dangling symlinks, $numReflexiveSymlinks reflexive symlinks and $numUnreadableSymlinks unreadable symlinks"; + exit 1; + fi; + return 0 +} +noBrokenSymlinksInAllOutputs () +{ + + if [[ -z ${dontCheckForBrokenSymlinks-} ]]; then + for output in $(getAllOutputNames); + do + noBrokenSymlinks "${!output}"; + done; + fi +} +patchPhase () +{ + + runHook prePatch; + local -a patchesArray; + concatTo patchesArray patches; + local -a flagsArray; + concatTo flagsArray patchFlags=-p1; + for i in "${patchesArray[@]}"; + do + echo "applying patch $i"; + local uncompress=cat; + case "$i" in + *.gz) + uncompress="gzip -d" + ;; + *.bz2) + uncompress="bzip2 -d" + ;; + *.xz) + uncompress="xz -d" + ;; + *.lzma) + uncompress="lzma -d" + ;; + esac; + $uncompress < "$i" 2>&1 | patch "${flagsArray[@]}"; + done; + runHook postPatch +} +patchShebangs () +{ + + local pathName; + local update=false; + while [[ $# -gt 0 ]]; do + case "$1" in + --host) + pathName=HOST_PATH; + shift + ;; + --build) + pathName=PATH; + shift + ;; + --update) + update=true; + shift + ;; + --) + shift; + break + ;; + -* | --*) + echo "Unknown option $1 supplied to patchShebangs" 1>&2; + return 1 + ;; + *) + break + ;; + esac; + done; + echo "patching script interpreter paths in $@"; + local f; + local oldPath; + local newPath; + local arg0; + local args; + local oldInterpreterLine; + local newInterpreterLine; + if [[ $# -eq 0 ]]; then + echo "No arguments supplied to patchShebangs" 1>&2; + return 0; + fi; + local f; + while IFS= read -r -d '' f; do + isScript "$f" || continue; + read -r oldInterpreterLine < "$f" || [ "$oldInterpreterLine" ]; + read -r oldPath arg0 args <<< "${oldInterpreterLine:2}"; + if [[ -z "${pathName:-}" ]]; then + if [[ -n $strictDeps && $f == "$NIX_STORE"* ]]; then + pathName=HOST_PATH; + else + pathName=PATH; + fi; + fi; + if [[ "$oldPath" == *"/bin/env" ]]; then + if [[ $arg0 == "-S" ]]; then + arg0=${args%% *}; + [[ "$args" == *" "* ]] && args=${args#* } || args=; + newPath="$(PATH="${!pathName}" type -P "env" || true)"; + args="-S $(PATH="${!pathName}" type -P "$arg0" || true) $args"; + else + if [[ $arg0 == "-"* || $arg0 == *"="* ]]; then + echo "$f: unsupported interpreter directive \"$oldInterpreterLine\" (set dontPatchShebangs=1 and handle shebang patching yourself)" 1>&2; + exit 1; + else + newPath="$(PATH="${!pathName}" type -P "$arg0" || true)"; + fi; + fi; + else + if [[ -z $oldPath ]]; then + oldPath="/bin/sh"; + fi; + newPath="$(PATH="${!pathName}" type -P "$(basename "$oldPath")" || true)"; + args="$arg0 $args"; + fi; + newInterpreterLine="$newPath $args"; + newInterpreterLine=${newInterpreterLine%${newInterpreterLine##*[![:space:]]}}; + if [[ -n "$oldPath" && ( "$update" == true || "${oldPath:0:${#NIX_STORE}}" != "$NIX_STORE" ) ]]; then + if [[ -n "$newPath" && "$newPath" != "$oldPath" ]]; then + echo "$f: interpreter directive changed from \"$oldInterpreterLine\" to \"$newInterpreterLine\""; + escapedInterpreterLine=${newInterpreterLine//\\/\\\\}; + timestamp=$(stat --printf "%y" "$f"); + tmpFile=$(mktemp -t patchShebangs.XXXXXXXXXX); + sed -e "1 s|.*|#\!$escapedInterpreterLine|" "$f" > "$tmpFile"; + local restoreReadOnly; + if [[ ! -w "$f" ]]; then + chmod +w "$f"; + restoreReadOnly=true; + fi; + cat "$tmpFile" > "$f"; + rm "$tmpFile"; + if [[ -n "${restoreReadOnly:-}" ]]; then + chmod -w "$f"; + fi; + touch --date "$timestamp" "$f"; + fi; + fi; + done < <(find "$@" -type f -perm -0100 -print0) +} +patchShebangsAuto () +{ + + if [[ -z "${dontPatchShebangs-}" && -e "$prefix" ]]; then + if [[ "$output" != out && "$output" = "$outputDev" ]]; then + patchShebangs --build "$prefix"; + else + patchShebangs --host "$prefix"; + fi; + fi +} +prependToVar () +{ + + local -n nameref="$1"; + local useArray type; + if [ -n "$__structuredAttrs" ]; then + useArray=true; + else + useArray=false; + fi; + if type=$(declare -p "$1" 2> /dev/null); then + case "${type#* }" in + -A*) + echo "prependToVar(): ERROR: trying to use prependToVar on an associative array." 1>&2; + return 1 + ;; + -a*) + useArray=true + ;; + *) + useArray=false + ;; + esac; + fi; + shift; + if $useArray; then + nameref=("$@" ${nameref+"${nameref[@]}"}); + else + nameref="$* ${nameref-}"; + fi +} +printLines () +{ + + (( "$#" > 0 )) || return 0; + printf '%s\n' "$@" +} +printPhases () +{ + + definePhases; + local phase; + for phase in ${phases[*]}; + do + printf '%s\n' "$phase"; + done +} +printWords () +{ + + (( "$#" > 0 )) || return 0; + printf '%s ' "$@" +} +recordPropagatedDependencies () +{ + + declare -ra flatVars=(depsBuildBuildPropagated propagatedNativeBuildInputs depsBuildTargetPropagated depsHostHostPropagated propagatedBuildInputs depsTargetTargetPropagated); + declare -ra flatFiles=("${propagatedBuildDepFiles[@]}" "${propagatedHostDepFiles[@]}" "${propagatedTargetDepFiles[@]}"); + local propagatedInputsIndex; + for propagatedInputsIndex in "${!flatVars[@]}"; + do + local propagatedInputsSlice="${flatVars[$propagatedInputsIndex]}[@]"; + local propagatedInputsFile="${flatFiles[$propagatedInputsIndex]}"; + [[ -n "${!propagatedInputsSlice}" ]] || continue; + mkdir -p "${!outputDev}/nix-support"; + printWords ${!propagatedInputsSlice} > "${!outputDev}/nix-support/$propagatedInputsFile"; + done +} +runHook () +{ + + local hookName="$1"; + shift; + local hooksSlice="${hookName%Hook}Hooks[@]"; + local hook; + for hook in "_callImplicitHook 0 $hookName" ${!hooksSlice+"${!hooksSlice}"}; + do + _logHook "$hookName" "$hook" "$@"; + _eval "$hook" "$@"; + done; + return 0 +} +runOneHook () +{ + + local hookName="$1"; + shift; + local hooksSlice="${hookName%Hook}Hooks[@]"; + local hook ret=1; + for hook in "_callImplicitHook 1 $hookName" ${!hooksSlice+"${!hooksSlice}"}; + do + _logHook "$hookName" "$hook" "$@"; + if _eval "$hook" "$@"; then + ret=0; + break; + fi; + done; + return "$ret" +} +runPhase () +{ + + local curPhase="$*"; + if [[ "$curPhase" = unpackPhase && -n "${dontUnpack:-}" ]]; then + return; + fi; + if [[ "$curPhase" = patchPhase && -n "${dontPatch:-}" ]]; then + return; + fi; + if [[ "$curPhase" = configurePhase && -n "${dontConfigure:-}" ]]; then + return; + fi; + if [[ "$curPhase" = buildPhase && -n "${dontBuild:-}" ]]; then + return; + fi; + if [[ "$curPhase" = checkPhase && -z "${doCheck:-}" ]]; then + return; + fi; + if [[ "$curPhase" = installPhase && -n "${dontInstall:-}" ]]; then + return; + fi; + if [[ "$curPhase" = fixupPhase && -n "${dontFixup:-}" ]]; then + return; + fi; + if [[ "$curPhase" = installCheckPhase && -z "${doInstallCheck:-}" ]]; then + return; + fi; + if [[ "$curPhase" = distPhase && -z "${doDist:-}" ]]; then + return; + fi; + showPhaseHeader "$curPhase"; + dumpVars; + local startTime endTime; + startTime=$(date +"%s"); + eval "${!curPhase:-$curPhase}"; + endTime=$(date +"%s"); + showPhaseFooter "$curPhase" "$startTime" "$endTime"; + if [ "$curPhase" = unpackPhase ]; then + [ -n "${sourceRoot:-}" ] && chmod +x -- "${sourceRoot}"; + cd -- "${sourceRoot:-.}"; + fi +} +showPhaseFooter () +{ + + local phase="$1"; + local startTime="$2"; + local endTime="$3"; + local delta=$(( endTime - startTime )); + (( delta < 30 )) && return; + local H=$((delta/3600)); + local M=$((delta%3600/60)); + local S=$((delta%60)); + echo -n "$phase completed in "; + (( H > 0 )) && echo -n "$H hours "; + (( M > 0 )) && echo -n "$M minutes "; + echo "$S seconds" +} +showPhaseHeader () +{ + + local phase="$1"; + echo "Running phase: $phase"; + if [[ -z ${NIX_LOG_FD-} ]]; then + return; + fi; + printf "@nix { \"action\": \"setPhase\", \"phase\": \"%s\" }\n" "$phase" >&"$NIX_LOG_FD" +} +stripDirs () +{ + + local cmd="$1"; + local ranlibCmd="$2"; + local paths="$3"; + local stripFlags="$4"; + local excludeFlags=(); + local pathsNew=; + [ -z "$cmd" ] && echo "stripDirs: Strip command is empty" 1>&2 && exit 1; + [ -z "$ranlibCmd" ] && echo "stripDirs: Ranlib command is empty" 1>&2 && exit 1; + local pattern; + if [ -n "${stripExclude:-}" ]; then + for pattern in "${stripExclude[@]}"; + do + excludeFlags+=(-a '!' '(' -name "$pattern" -o -wholename "$prefix/$pattern" ')'); + done; + fi; + local p; + for p in ${paths}; + do + if [ -e "$prefix/$p" ]; then + pathsNew="${pathsNew} $prefix/$p"; + fi; + done; + paths=${pathsNew}; + if [ -n "${paths}" ]; then + echo "stripping (with command $cmd and flags $stripFlags) in $paths"; + local striperr; + striperr="$(mktemp --tmpdir="$TMPDIR" 'striperr.XXXXXX')"; + find $paths -type f "${excludeFlags[@]}" -a '!' -path "$prefix/lib/debug/*" -printf '%D-%i,%p\0' | sort -t, -k1,1 -u -z | cut -d, -f2- -z | xargs -r -0 -n1 -P "$NIX_BUILD_CORES" -- $cmd $stripFlags 2> "$striperr" || exit_code=$?; + [[ "$exit_code" = 123 || -z "$exit_code" ]] || ( cat "$striperr" 1>&2 && exit 1 ); + rm "$striperr"; + find $paths -name '*.a' -type f -exec $ranlibCmd '{}' \; 2> /dev/null; + fi +} +stripHash () +{ + + local strippedName casematchOpt=0; + strippedName="$(basename -- "$1")"; + shopt -q nocasematch && casematchOpt=1; + shopt -u nocasematch; + if [[ "$strippedName" =~ ^[a-z0-9]{32}- ]]; then + echo "${strippedName:33}"; + else + echo "$strippedName"; + fi; + if (( casematchOpt )); then + shopt -s nocasematch; + fi +} +substitute () +{ + + local input="$1"; + local output="$2"; + shift 2; + if [ ! -f "$input" ]; then + echo "substitute(): ERROR: file '$input' does not exist" 1>&2; + return 1; + fi; + local content; + consumeEntire content < "$input"; + if [ -e "$output" ]; then + chmod +w "$output"; + fi; + substituteStream content "file '$input'" "$@" > "$output" +} +substituteAll () +{ + + local input="$1"; + local output="$2"; + local -a args=(); + _allFlags; + substitute "$input" "$output" "${args[@]}" +} +substituteAllInPlace () +{ + + local fileName="$1"; + shift; + substituteAll "$fileName" "$fileName" "$@" +} +substituteAllStream () +{ + + local -a args=(); + _allFlags; + substituteStream "$1" "$2" "${args[@]}" +} +substituteInPlace () +{ + + local -a fileNames=(); + for arg in "$@"; + do + if [[ "$arg" = "--"* ]]; then + break; + fi; + fileNames+=("$arg"); + shift; + done; + if ! [[ "${#fileNames[@]}" -gt 0 ]]; then + echo "substituteInPlace called without any files to operate on (files must come before options!)" 1>&2; + return 1; + fi; + for file in "${fileNames[@]}"; + do + substitute "$file" "$file" "$@"; + done +} +substituteStream () +{ + + local var=$1; + local description=$2; + shift 2; + while (( "$#" )); do + local replace_mode="$1"; + case "$1" in + --replace) + if ! "$_substituteStream_has_warned_replace_deprecation"; then + echo "substituteStream() in derivation $name: WARNING: '--replace' is deprecated, use --replace-{fail,warn,quiet}. ($description)" 1>&2; + _substituteStream_has_warned_replace_deprecation=true; + fi; + replace_mode='--replace-warn' + ;& + --replace-quiet | --replace-warn | --replace-fail) + pattern="$2"; + replacement="$3"; + shift 3; + if ! [[ "${!var}" == *"$pattern"* ]]; then + if [ "$replace_mode" == --replace-warn ]; then + printf "substituteStream() in derivation $name: WARNING: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2; + else + if [ "$replace_mode" == --replace-fail ]; then + printf "substituteStream() in derivation $name: ERROR: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2; + return 1; + fi; + fi; + fi; + eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}' + ;; + --subst-var) + local varName="$2"; + shift 2; + if ! [[ "$varName" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then + echo "substituteStream() in derivation $name: ERROR: substitution variables must be valid Bash names, \"$varName\" isn't." 1>&2; + return 1; + fi; + if [ -z ${!varName+x} ]; then + echo "substituteStream() in derivation $name: ERROR: variable \$$varName is unset" 1>&2; + return 1; + fi; + pattern="@$varName@"; + replacement="${!varName}"; + eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}' + ;; + --subst-var-by) + pattern="@$2@"; + replacement="$3"; + eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'; + shift 3 + ;; + *) + echo "substituteStream() in derivation $name: ERROR: Invalid command line argument: $1" 1>&2; + return 1 + ;; + esac; + done; + printf "%s" "${!var}" +} +unpackFile () +{ + + curSrc="$1"; + echo "unpacking source archive $curSrc"; + if ! runOneHook unpackCmd "$curSrc"; then + echo "do not know how to unpack source archive $curSrc"; + exit 1; + fi +} +unpackPhase () +{ + + runHook preUnpack; + if [ -z "${srcs:-}" ]; then + if [ -z "${src:-}" ]; then + echo 'variable $src or $srcs should point to the source'; + exit 1; + fi; + srcs="$src"; + fi; + local -a srcsArray; + concatTo srcsArray srcs; + local dirsBefore=""; + for i in *; + do + if [ -d "$i" ]; then + dirsBefore="$dirsBefore $i "; + fi; + done; + for i in "${srcsArray[@]}"; + do + unpackFile "$i"; + done; + : "${sourceRoot=}"; + if [ -n "${setSourceRoot:-}" ]; then + runOneHook setSourceRoot; + else + if [ -z "$sourceRoot" ]; then + for i in *; + do + if [ -d "$i" ]; then + case $dirsBefore in + *\ $i\ *) + + ;; + *) + if [ -n "$sourceRoot" ]; then + echo "unpacker produced multiple directories"; + exit 1; + fi; + sourceRoot="$i" + ;; + esac; + fi; + done; + fi; + fi; + if [ -z "$sourceRoot" ]; then + echo "unpacker appears to have produced no directories"; + exit 1; + fi; + echo "source root is $sourceRoot"; + if [ "${dontMakeSourcesWritable:-0}" != 1 ]; then + chmod -R u+w -- "$sourceRoot"; + fi; + runHook postUnpack +} +updateAutotoolsGnuConfigScriptsPhase () +{ + + if [ -n "${dontUpdateAutotoolsGnuConfigScripts-}" ]; then + return; + fi; + for script in config.sub config.guess; + do + for f in $(find . -type f -name "$script"); + do + echo "Updating Autotools / GNU config script to a newer upstream version: $f"; + cp -f "/nix/store/wr33c2hmpj37sly95dzmy8b5jzpdm5gq-gnu-config-2024-01-01/$script" "$f"; + done; + done +} +updateSourceDateEpoch () +{ + + local path="$1"; + [[ $path == -* ]] && path="./$path"; + local -a res=($(find "$path" -type f -not -newer "$NIX_BUILD_TOP/.." -printf '%T@ "%p"\0' | sort -n --zero-terminated | tail -n1 --zero-terminated | head -c -1)); + local time="${res[0]//\.[0-9]*/}"; + local newestFile="${res[1]}"; + if [ "${time:-0}" -gt "$SOURCE_DATE_EPOCH" ]; then + echo "setting SOURCE_DATE_EPOCH to timestamp $time of file $newestFile"; + export SOURCE_DATE_EPOCH="$time"; + local now="$(date +%s)"; + if [ "$time" -gt $((now - 60)) ]; then + echo "warning: file $newestFile may be generated; SOURCE_DATE_EPOCH may be non-deterministic"; + fi; + fi +} +PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}" +XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}" +export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)" +export TMP="$NIX_BUILD_TOP" +export TMPDIR="$NIX_BUILD_TOP" +export TEMP="$NIX_BUILD_TOP" +export TEMPDIR="$NIX_BUILD_TOP" +eval "${shellHook:-}" diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b5a49d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..73b666d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2851 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codex-controller-loop" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "crossterm 0.29.0", + "ratatui 0.30.0", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "toon-format", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm 0.29.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags 2.11.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str 0.8.1", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate 1.1.0", + "unicode-width 0.2.0", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.0", + "compact_str 0.9.0", + "hashbrown 0.16.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru 0.16.3", + "strum 0.27.2", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate 2.0.1", + "unicode-width 0.2.0", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum 0.27.2", + "time", + "unicode-segmentation", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.0", + "fancy-regex 0.11.0", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiktoken-rs" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a19830747d9034cd9da43a60eaa8e552dfda7712424aebf187b7a60126bae0d" +dependencies = [ + "anyhow", + "base64", + "bstr", + "fancy-regex 0.13.0", + "lazy_static", + "regex", + "rustc-hash", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toon-format" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5933bebbba70ee979314a8ecb021f53075a63984f94f89a10b4bdcf0af6c62b6" +dependencies = [ + "anyhow", + "arboard", + "chrono", + "clap", + "comfy-table", + "crossterm 0.28.1", + "indexmap", + "ratatui 0.29.0", + "serde", + "serde_json", + "syntect", + "thiserror 2.0.18", + "tiktoken-rs", + "tui-textarea", + "unicode-width 0.2.0", +] + +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm 0.28.1", + "ratatui 0.29.0", + "unicode-width 0.2.0", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f7c7d26 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "codex-controller-loop" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.102" +base64 = "0.22.1" +clap = { version = "4.6.0", features = ["derive"] } +crossterm = "0.29.0" +ratatui = "0.30.0" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +tempfile = "3.27.0" +thiserror = "2.0.18" +toon-format = "0.4.5" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e7b2a8 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# `codex-controller-loop` + +Rust TUI-first autonomous controller with TOON-backed machine state. + +## Product Shape + +`codex-controller-loop` now targets a single full-screen terminal experience: + +- center session stream for planning and execution events +- persistent plan board on the right +- bottom composer for planning replies or execution commands +- Codex-backed planning and Codex-backed per-step execution + +The controller has a hard two-phase model: + +1. Planning + - user can provide the goal or answer Codex follow-up questions +2. Executing + - controller runs autonomously + - user can only pause, resume, stop, inspect status, or update the goal + +## Storage + +Controller-local artifacts live under `.agent/controllers//`: + +```text +goal.md +plan.toon +state.toon +standards.md +``` + +The repo-local task config lives at: + +```text +.agent/controller-loop/task.toon +``` + +## Commands + +```bash +codex-controller-loop +codex-controller-loop init --task-id controller-loop +codex-controller-loop status +codex-controller-loop run +``` + +`codex-controller-loop` defaults to the TUI. + +## Build + +Use Nix or Cargo: + +```bash +nix develop -c cargo test +nix develop -c cargo run +``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..03cb250 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1775036866, + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..162fe45 --- /dev/null +++ b/flake.nix @@ -0,0 +1,48 @@ +{ + description = "Standalone Codex controller loop framework"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + codex-controller-loop = import ./nix/packages/codex-controller-loop.nix { inherit pkgs; }; + in + { + packages = { + inherit codex-controller-loop; + default = codex-controller-loop; + }; + + apps = { + codex-controller-loop = { + type = "app"; + program = "${codex-controller-loop}/bin/codex-controller-loop"; + }; + default = self.apps.${system}.codex-controller-loop; + }; + + devShells.default = pkgs.mkShell { + packages = [ + pkgs.git + pkgs.rustc + pkgs.cargo + pkgs.clippy + pkgs.rustfmt + pkgs.rust-analyzer + ]; + }; + } + ); +} diff --git a/nix/packages/codex-controller-loop.nix b/nix/packages/codex-controller-loop.nix new file mode 100644 index 0000000..3b6f936 --- /dev/null +++ b/nix/packages/codex-controller-loop.nix @@ -0,0 +1,12 @@ +{ pkgs }: +pkgs.rustPlatform.buildRustPackage { + pname = "codex-controller-loop"; + version = "0.1.0"; + src = ../..; + cargoLock.lockFile = ../../Cargo.lock; + + meta = { + description = "Standalone Codex controller loop framework"; + mainProgram = "codex-controller-loop"; + }; +} diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 0000000..d2f5311 --- /dev/null +++ b/src/app/input.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::model::Screen; + +use super::App; + +impl App { + pub(super) fn handle_key(&mut self, key: KeyEvent) -> Result { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + return Ok(true); + } + + match self.screen { + Screen::ControllerPicker => self.handle_picker_key(key), + Screen::CreateController => self.handle_create_key(key), + Screen::Workspace => self.handle_workspace_key(key), + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..8748331 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,215 @@ +mod input; +mod picker; +mod runtime; +mod session; +mod workspace_input; + +#[cfg(test)] +mod tests; + +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender}; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use crossterm::{ + event::{self, Event, KeyEvent}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +use crate::cli::DEFAULT_TASK_CONFIG_PATH; +use crate::model::{ + group_session_entries, ControllerPhase, ControllerState, Plan, Screen, SessionEntry, + SessionSelection, StatusSnapshot, TaskConfig, UsageSnapshot, +}; +use crate::ui; + +pub(crate) const USAGE_REFRESH_INTERVAL: Duration = Duration::from_secs(120); + +#[derive(Debug, Clone)] +pub enum AppEvent { + Session(SessionEntry), + Snapshot { + goal_md: String, + standards_md: String, + plan: Plan, + state: ControllerState, + }, + CodexUsage { + input_tokens: u64, + output_tokens: u64, + }, +} + +#[derive(Debug)] +pub enum ControlCommand { + Submit(String), + Pause, + Resume, + Stop, + Quit, +} + +pub(crate) struct WorkspaceRuntime { + pub(crate) task_config: TaskConfig, + pub(crate) goal_md: String, + pub(crate) standards_md: String, + pub(crate) plan: Plan, + pub(crate) state: ControllerState, + pub(crate) input: String, + pub(crate) session_entries: Vec, + pub(crate) event_rx: Receiver, + pub(crate) control_tx: Sender, + pub(crate) session_input_tokens: Option, + pub(crate) session_output_tokens: Option, + pub(crate) usage_snapshot: UsageSnapshot, + pub(crate) last_usage_refresh: Instant, + pub(crate) session_scroll: usize, + pub(crate) session_follow_output: bool, + pub(crate) session_viewport_lines: usize, + pub(crate) session_selection: Option, + pub(crate) session_drag_active: bool, +} + +pub struct App { + pub screen: Screen, + pub picker_items: Vec, + pub picker_selected: usize, + pub create_input: String, + pub create_error: Option, + pub default_task_path: PathBuf, + pub(crate) workspace: Option, +} + +impl App { + pub fn bootstrap(task_path: Option) -> Result { + let default_task_path = PathBuf::from(DEFAULT_TASK_CONFIG_PATH); + let mut app = Self { + screen: Screen::ControllerPicker, + picker_items: Vec::new(), + picker_selected: 0, + create_input: String::new(), + create_error: None, + default_task_path: default_task_path.clone(), + workspace: None, + }; + + if let Some(task_path) = task_path { + app.open_workspace_from_task_file(task_path)?; + } else { + app.refresh_picker()?; + } + + Ok(app) + } + + pub fn run(&mut self) -> Result<()> { + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = self.run_loop(&mut terminal); + + self.shutdown_runtime(); + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + crossterm::event::DisableMouseCapture, + LeaveAlternateScreen + )?; + terminal.show_cursor()?; + + result + } + + pub fn workspace_groups(&self) -> Vec { + self.workspace + .as_ref() + .map(|workspace| group_session_entries(&workspace.session_entries)) + .unwrap_or_default() + } + + pub fn workspace_status_snapshot(&self) -> Option { + let workspace = self.workspace.as_ref()?; + Some(StatusSnapshot { + controller_id: workspace.task_config.controller_id(), + branch: workspace.task_config.branch.clone(), + started_at: workspace.state.started_at.clone(), + phase: workspace.state.phase.clone(), + iteration: workspace.state.iteration, + session_input_tokens: workspace.session_input_tokens, + session_output_tokens: workspace.session_output_tokens, + usage: workspace.usage_snapshot.clone(), + }) + } + + pub(crate) fn workspace(&self) -> Option<&WorkspaceRuntime> { + self.workspace.as_ref() + } + + pub(crate) fn workspace_input(&self) -> Option<&str> { + self.workspace + .as_ref() + .map(|workspace| workspace.input.as_str()) + } + + pub(crate) fn workspace_session_selection(&self) -> Option<&SessionSelection> { + self.workspace + .as_ref() + .and_then(|workspace| workspace.session_selection.as_ref()) + } + + pub(crate) fn workspace_session_scroll(&self) -> usize { + let Some(workspace) = self.workspace.as_ref() else { + return 0; + }; + + let max_scroll = self + .workspace_session_line_count() + .saturating_sub(workspace.session_viewport_lines); + if workspace.session_follow_output { + max_scroll + } else { + workspace.session_scroll.min(max_scroll) + } + } + + pub(crate) fn workspace_session_line_count(&self) -> usize { + self.workspace + .as_ref() + .map(|workspace| Self::session_line_count_for_entries(&workspace.session_entries)) + .unwrap_or_default() + } + + fn run_loop( + &mut self, + terminal: &mut Terminal>, + ) -> Result<()> { + loop { + self.drain_workspace_events()?; + self.maybe_refresh_usage()?; + self.update_workspace_viewport(terminal.size()?.height as usize); + + terminal.draw(|frame| ui::render(frame, self))?; + + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) => { + if self.handle_key(key)? { + break; + } + } + Event::Mouse(mouse) => self.handle_mouse(mouse, terminal.size()?.into())?, + Event::Resize(_, height) => self.update_workspace_viewport(height as usize), + _ => {} + } + } + } + + Ok(()) + } +} diff --git a/src/app/picker.rs b/src/app/picker.rs new file mode 100644 index 0000000..8a9f198 --- /dev/null +++ b/src/app/picker.rs @@ -0,0 +1,91 @@ +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; + +use crate::model::Screen; + +use super::App; + +impl App { + pub(super) fn handle_picker_key(&mut self, key: KeyEvent) -> Result { + let total_rows = self.picker_items.len() + 1; + match key.code { + KeyCode::Esc => Ok(true), + KeyCode::Down | KeyCode::Char('j') => { + if self.picker_selected + 1 < total_rows { + self.picker_selected += 1; + } + Ok(false) + } + KeyCode::Up | KeyCode::Char('k') => { + if self.picker_selected > 0 { + self.picker_selected -= 1; + } + Ok(false) + } + KeyCode::Char('n') => { + self.screen = Screen::CreateController; + self.create_error = None; + Ok(false) + } + KeyCode::Enter => { + if self.picker_selected == self.picker_items.len() { + self.screen = Screen::CreateController; + self.create_error = None; + return Ok(false); + } + + if let Some(controller_id) = self + .picker_items + .get(self.picker_selected) + .map(|controller| controller.id.clone()) + { + let config = crate::model::TaskConfig::default_for(&controller_id); + self.open_workspace(config, Some(self.default_task_path.clone()))?; + } + Ok(false) + } + _ => Ok(false), + } + } + + pub(super) fn handle_create_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Esc => { + self.screen = Screen::ControllerPicker; + self.create_error = None; + Ok(false) + } + KeyCode::Backspace => { + self.create_input.pop(); + self.create_error = None; + Ok(false) + } + KeyCode::Enter => { + let goal = self.create_input.trim().to_string(); + if goal.is_empty() { + self.create_error = + Some("Enter the work this controller should own.".to_string()); + return Ok(false); + } + + match self.create_workspace_from_goal(goal.clone()) { + Ok(()) => { + self.submit_workspace_input(goal)?; + self.create_input.clear(); + self.create_error = None; + } + Err(error) => { + self.create_error = Some(error.to_string()); + } + } + Ok(false) + } + KeyCode::Char(ch) => { + self.create_input.push(ch); + self.create_error = None; + Ok(false) + } + _ => Ok(false), + } + } +} diff --git a/src/app/runtime.rs b/src/app/runtime.rs new file mode 100644 index 0000000..0c9d039 --- /dev/null +++ b/src/app/runtime.rs @@ -0,0 +1,241 @@ +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread; +use std::time::Instant; + +use anyhow::{anyhow, Result}; + +use crate::controller::engine; +use crate::model::{SessionEntry, SessionSource, SessionStream, TaskConfig, UsageSnapshot}; +use crate::repo; +use crate::storage::toon; + +use super::{App, AppEvent, ControlCommand, WorkspaceRuntime, USAGE_REFRESH_INTERVAL}; + +impl App { + pub(super) fn open_workspace_from_task_file(&mut self, task_path: PathBuf) -> Result<()> { + let config = toon::read_task_config(&task_path)?; + self.open_workspace(config, Some(task_path)) + } + + pub(super) fn create_workspace_from_goal(&mut self, goal: String) -> Result<()> { + let suggested_id = crate::process::generate_controller_id(&repo::repo_root(), &goal) + .map_err(|error| anyhow!("Failed to generate controller id with GPT-5.4 mini: {error:#}"))?; + let controller_id = toon::make_unique_controller_id(&suggested_id); + let config = toon::create_controller(&self.default_task_path, &controller_id)?; + self.open_workspace(config, Some(self.default_task_path.clone())) + } + + pub(super) fn open_workspace( + &mut self, + config: TaskConfig, + persist_task_path: Option, + ) -> Result<()> { + self.shutdown_runtime(); + + if let Some(task_path) = persist_task_path.as_ref() { + toon::write_task_config(task_path, &config)?; + } + toon::ensure_controller_files(&config)?; + + let mut state = toon::read_state(&config.state_file)?; + if state.started_at.is_none() { + state.started_at = Some(repo::now_timestamp()); + toon::write_state(&config.state_file, &state)?; + } + + let goal_md = toon::read_markdown(&config.goal_file)?; + let standards_md = toon::read_markdown(&config.standards_file)?; + let plan = toon::read_plan(&config.plan_file)?; + + let usage_snapshot = if state.last_usage_input_tokens.is_some() + || state.last_usage_output_tokens.is_some() + { + UsageSnapshot { + input_tokens: state.last_usage_input_tokens, + output_tokens: state.last_usage_output_tokens, + refreshed_at: state.last_usage_refresh_at.clone(), + available: true, + note: None, + } + } else { + UsageSnapshot::unavailable("usage unavailable") + }; + + let (event_tx, event_rx) = mpsc::channel(); + let (control_tx, control_rx) = mpsc::channel(); + let repo_root = repo::repo_root(); + let worker_config = config.clone(); + thread::spawn(move || { + if let Err(error) = + engine::runtime_loop(repo_root, worker_config, control_rx, event_tx.clone()) + { + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Warning, + stream: SessionStream::Stderr, + title: "Runtime".to_string(), + tag: None, + body: format!("controller runtime error: {error:#}"), + run_id: repo::next_run_id(), + })); + } + }); + + let session_entries = vec![SessionEntry { + source: SessionSource::Controller, + stream: SessionStream::Status, + title: "Session".to_string(), + tag: Some(config.controller_id()), + body: format!( + "Loaded {} and opened controller workspace.", + config.plan_file.display() + ), + run_id: repo::next_run_id(), + }]; + + self.workspace = Some(WorkspaceRuntime { + task_config: config, + goal_md, + standards_md, + plan, + state, + input: String::new(), + session_entries, + event_rx, + control_tx, + session_input_tokens: None, + session_output_tokens: None, + usage_snapshot, + last_usage_refresh: Instant::now(), + session_scroll: 0, + session_follow_output: true, + session_viewport_lines: 0, + session_selection: None, + session_drag_active: false, + }); + self.screen = crate::model::Screen::Workspace; + self.refresh_picker()?; + Ok(()) + } + + pub(super) fn refresh_picker(&mut self) -> Result<()> { + self.picker_items = toon::list_controller_summaries()?; + if self.picker_selected > self.picker_items.len() { + self.picker_selected = self.picker_items.len(); + } + Ok(()) + } + + pub(super) fn drain_workspace_events(&mut self) -> Result<()> { + let Some(workspace) = self.workspace.as_mut() else { + return Ok(()); + }; + + let mut events = Vec::new(); + while let Ok(event) = workspace.event_rx.try_recv() { + events.push(event); + } + for event in events { + self.apply_event(event)?; + } + Ok(()) + } + + fn apply_event(&mut self, event: AppEvent) -> Result<()> { + let Some(workspace) = self.workspace.as_mut() else { + return Ok(()); + }; + + match event { + AppEvent::Session(entry) => { + if workspace.session_follow_output { + workspace.session_scroll = + Self::session_line_count_for_entries(&workspace.session_entries) + .saturating_sub(workspace.session_viewport_lines); + } + workspace.session_selection = None; + workspace.session_drag_active = false; + workspace.session_entries.push(entry); + } + AppEvent::Snapshot { + goal_md, + standards_md, + plan, + state, + } => { + workspace.goal_md = goal_md; + workspace.standards_md = standards_md; + workspace.plan = plan; + workspace.state = state.clone(); + if state.last_usage_input_tokens.is_some() + || state.last_usage_output_tokens.is_some() + { + workspace.usage_snapshot = UsageSnapshot { + input_tokens: state.last_usage_input_tokens, + output_tokens: state.last_usage_output_tokens, + refreshed_at: state.last_usage_refresh_at, + available: true, + note: None, + }; + } + } + AppEvent::CodexUsage { + input_tokens, + output_tokens, + } => { + let input_total = workspace.session_input_tokens.get_or_insert(0); + *input_total += input_tokens; + let output_total = workspace.session_output_tokens.get_or_insert(0); + *output_total += output_tokens; + } + } + Ok(()) + } + + pub(super) fn maybe_refresh_usage(&mut self) -> Result<()> { + let Some(workspace) = self.workspace.as_mut() else { + return Ok(()); + }; + if workspace.last_usage_refresh.elapsed() < USAGE_REFRESH_INTERVAL { + return Ok(()); + } + + let snapshot = crate::process::refresh_usage_snapshot(&workspace.state); + workspace.last_usage_refresh = Instant::now(); + workspace.usage_snapshot = snapshot.clone(); + workspace.state.last_usage_refresh_at = snapshot.refreshed_at.clone(); + workspace.state.last_usage_input_tokens = snapshot.input_tokens; + workspace.state.last_usage_output_tokens = snapshot.output_tokens; + toon::write_state(&workspace.task_config.state_file, &workspace.state)?; + Ok(()) + } + + pub(super) fn push_local_entry( + &mut self, + source: SessionSource, + stream: SessionStream, + title: &str, + tag: Option, + body: &str, + ) { + if let Some(workspace) = self.workspace.as_mut() { + workspace.session_follow_output = true; + workspace.session_selection = None; + workspace.session_drag_active = false; + workspace.session_entries.push(SessionEntry { + source, + stream, + title: title.to_string(), + tag, + body: body.to_string(), + run_id: repo::next_run_id(), + }); + } + } + + pub(super) fn shutdown_runtime(&mut self) { + if let Some(workspace) = self.workspace.take() { + let _ = workspace.control_tx.send(ControlCommand::Quit); + } + } +} diff --git a/src/app/session.rs b/src/app/session.rs new file mode 100644 index 0000000..28efdc3 --- /dev/null +++ b/src/app/session.rs @@ -0,0 +1,156 @@ +use base64::Engine; +use std::io::Write; + +use anyhow::Result; +use crossterm::event::MouseEvent; + +use crate::model::{group_session_entries, SessionCursor, SessionEntry}; +use crate::ui; + +use super::App; + +impl App { + pub(super) fn session_line_count_for_entries(entries: &[SessionEntry]) -> usize { + group_session_entries(entries) + .iter() + .map(|group| group.lines.len() + 3) + .sum() + } + + pub(super) fn scroll_session_by(&mut self, delta: isize) { + let Some(workspace) = self.workspace.as_mut() else { + return; + }; + + let max_scroll = Self::session_line_count_for_entries(&workspace.session_entries) + .saturating_sub(workspace.session_viewport_lines); + let current = if workspace.session_follow_output { + max_scroll + } else { + workspace.session_scroll + }; + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize).min(max_scroll) + }; + + workspace.session_scroll = next; + workspace.session_follow_output = false; + } + + pub(super) fn jump_session_to_start(&mut self) { + if let Some(workspace) = self.workspace.as_mut() { + workspace.session_scroll = 0; + workspace.session_follow_output = false; + } + } + + pub(super) fn follow_session_output(&mut self) { + if let Some(workspace) = self.workspace.as_mut() { + workspace.session_follow_output = true; + } + } + + pub(super) fn update_workspace_viewport(&mut self, terminal_height: usize) { + if let Some(workspace) = self.workspace.as_mut() { + workspace.session_viewport_lines = terminal_height.saturating_sub(8); + } + } + + pub(super) fn mouse_over_session_text( + &self, + mouse: MouseEvent, + terminal_area: ratatui::layout::Rect, + ) -> bool { + let rows = ui::build_session_render_rows(&self.workspace_groups()); + ui::workspace_layout(terminal_area) + .session_text_rect(rows.len() > self.visible_session_lines(terminal_area)) + .contains(ratatui::layout::Position::new(mouse.column, mouse.row)) + } + + pub(super) fn session_cursor_from_mouse( + &self, + mouse: MouseEvent, + terminal_area: ratatui::layout::Rect, + clamp: bool, + ) -> Option { + let rows = ui::build_session_render_rows(&self.workspace_groups()); + if rows.is_empty() { + return None; + } + + let layout = ui::workspace_layout(terminal_area); + let text_rect = + layout.session_text_rect(rows.len() > self.visible_session_lines(terminal_area)); + if text_rect.width == 0 || text_rect.height == 0 { + return None; + } + + let point = ratatui::layout::Position::new(mouse.column, mouse.row); + if !clamp && !text_rect.contains(point) { + return None; + } + + let clamped_row = mouse.row.clamp( + text_rect.y, + text_rect + .y + .saturating_add(text_rect.height.saturating_sub(1)), + ); + let clamped_col = mouse.column.clamp( + text_rect.x, + text_rect + .x + .saturating_add(text_rect.width.saturating_sub(1)), + ); + let line_index = + self.workspace_session_scroll() + clamped_row.saturating_sub(text_rect.y) as usize; + let row = rows.get(line_index.min(rows.len().saturating_sub(1)))?; + + if !clamp { + let (start, end) = row.selectable_range?; + let rel_col = clamped_col.saturating_sub(text_rect.x) as usize; + if rel_col < start || rel_col >= end { + return None; + } + return Some(SessionCursor { + line: line_index.min(rows.len().saturating_sub(1)), + column: rel_col, + }); + } + + let column = match row.selectable_range { + Some((start, end)) if end > start => { + let rel_col = clamped_col.saturating_sub(text_rect.x) as usize; + rel_col.clamp(start, end.saturating_sub(1)) + } + _ => 0, + }; + + Some(SessionCursor { + line: line_index.min(rows.len().saturating_sub(1)), + column, + }) + } + + pub(super) fn selected_session_text(&self) -> Option { + let selection = self.workspace_session_selection()?; + let rows = ui::build_session_render_rows(&self.workspace_groups()); + ui::selected_session_text(&rows, selection) + } + + fn visible_session_lines(&self, terminal_area: ratatui::layout::Rect) -> usize { + ui::workspace_layout(terminal_area) + .session_text_rect(self.workspace_session_line_count() > 0) + .height as usize + } +} + +pub(super) fn copy_to_clipboard_osc52(text: &str) -> Result<()> { + let encoded = base64::engine::general_purpose::STANDARD.encode(text); + let mut stdout = std::io::stdout(); + write!(stdout, "\u{1b}]52;c;{encoded}\u{7}")?; + stdout.flush()?; + Ok(()) +} diff --git a/src/app/tests.rs b/src/app/tests.rs new file mode 100644 index 0000000..2102fe9 --- /dev/null +++ b/src/app/tests.rs @@ -0,0 +1,117 @@ +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Instant; + +use crossterm::event::{KeyCode, KeyEvent}; + +use super::{App, WorkspaceRuntime}; +use crate::cli::DEFAULT_TASK_CONFIG_PATH; +use crate::model::{ + ControllerPhase, ControllerState, Plan, Screen, SessionEntry, SessionSource, SessionStream, + TaskConfig, UsageSnapshot, +}; + +fn sample_app() -> App { + let (event_tx, event_rx) = mpsc::channel(); + let (control_tx, _control_rx) = mpsc::channel(); + drop(event_tx); + App { + screen: Screen::ControllerPicker, + picker_items: vec![crate::model::ControllerSummary { + id: "alpha".to_string(), + goal_summary: "Ship the picker".to_string(), + phase: ControllerPhase::Planning, + current_step_id: None, + completed_steps: 0, + total_steps: 2, + last_updated: Some("10".to_string()), + branch: "codex/alpha".to_string(), + }], + picker_selected: 0, + create_input: String::new(), + create_error: None, + default_task_path: PathBuf::from(DEFAULT_TASK_CONFIG_PATH), + workspace: Some(WorkspaceRuntime { + task_config: TaskConfig::default_for("alpha"), + goal_md: String::new(), + standards_md: String::new(), + plan: Plan::default(), + state: ControllerState::default(), + input: String::new(), + session_entries: Vec::new(), + event_rx, + control_tx, + session_input_tokens: None, + session_output_tokens: None, + usage_snapshot: UsageSnapshot::unavailable("usage unavailable"), + last_usage_refresh: Instant::now(), + session_scroll: 0, + session_follow_output: true, + session_viewport_lines: 0, + session_selection: None, + session_drag_active: false, + }), + } +} + +#[test] +fn picker_navigation_can_select_create_row() { + let mut app = sample_app(); + app.workspace = None; + app.handle_picker_key(KeyEvent::from(KeyCode::Down)) + .expect("move selection"); + assert_eq!(app.picker_selected, 1); + app.handle_picker_key(KeyEvent::from(KeyCode::Enter)) + .expect("enter"); + assert!(matches!(app.screen, Screen::CreateController)); +} + +#[test] +fn planning_mode_blocks_slash_commands() { + let mut app = sample_app(); + app.screen = Screen::Workspace; + app.dispatch_workspace_input("/pause".to_string()) + .expect("dispatch"); + let last = app + .workspace + .as_ref() + .and_then(|workspace| workspace.session_entries.last()) + .expect("warning entry"); + assert_eq!(last.source, SessionSource::Warning); + assert!(last.body.contains("Slash commands")); +} + +#[test] +fn workspace_scroll_can_move_away_from_follow_mode() { + let mut app = sample_app(); + app.screen = Screen::Workspace; + if let Some(workspace) = app.workspace.as_mut() { + workspace.session_entries = vec![ + SessionEntry { + source: SessionSource::Planner, + stream: SessionStream::Stdout, + title: "Thought".to_string(), + tag: None, + body: "line one\nline two\nline three\nline four".to_string(), + run_id: 1, + }, + SessionEntry { + source: SessionSource::Verifier, + stream: SessionStream::Stdout, + title: "Tests".to_string(), + tag: None, + body: "line five\nline six".to_string(), + run_id: 2, + }, + ]; + workspace.session_viewport_lines = 3; + } + + app.handle_workspace_key(KeyEvent::from(KeyCode::Up)) + .expect("scroll up"); + assert_eq!(app.workspace_session_scroll(), 8); + + app.handle_workspace_key(KeyEvent::from(KeyCode::Home)) + .expect("home"); + assert_eq!(app.workspace_session_scroll(), 0); +} diff --git a/src/app/workspace_input.rs b/src/app/workspace_input.rs new file mode 100644 index 0000000..05b0c31 --- /dev/null +++ b/src/app/workspace_input.rs @@ -0,0 +1,309 @@ +use anyhow::{anyhow, Result}; +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; + +use crate::model::{ + ControllerPhase, SessionEntry, SessionSelection, SessionSource, SessionStream, +}; +use crate::repo; +use crate::storage::toon; + +use super::{App, ControlCommand}; + +impl App { + pub(super) fn handle_mouse( + &mut self, + mouse: MouseEvent, + terminal_area: ratatui::layout::Rect, + ) -> Result<()> { + if !matches!(self.screen, crate::model::Screen::Workspace) { + return Ok(()); + } + + match mouse.kind { + MouseEventKind::ScrollUp => { + if self.mouse_over_session_text(mouse, terminal_area) { + self.scroll_session_by(-3); + } + } + MouseEventKind::ScrollDown => { + if self.mouse_over_session_text(mouse, terminal_area) { + self.scroll_session_by(3); + } + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(cursor) = self.session_cursor_from_mouse(mouse, terminal_area, false) { + if let Some(workspace) = self.workspace.as_mut() { + workspace.session_selection = Some(SessionSelection { + anchor: cursor, + focus: cursor, + }); + workspace.session_drag_active = true; + } + } else if let Some(workspace) = self.workspace.as_mut() { + workspace.session_drag_active = false; + workspace.session_selection = None; + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(cursor) = self.session_cursor_from_mouse(mouse, terminal_area, true) { + if let Some(workspace) = self.workspace.as_mut() { + if workspace.session_drag_active { + if let Some(selection) = workspace.session_selection.as_mut() { + selection.focus = cursor; + } + } + } + } + } + MouseEventKind::Up(MouseButton::Left) => { + let selection_text = if let Some(cursor) = + self.session_cursor_from_mouse(mouse, terminal_area, true) + { + if let Some(workspace) = self.workspace.as_mut() { + if workspace.session_drag_active { + if let Some(selection) = workspace.session_selection.as_mut() { + selection.focus = cursor; + } + } + } + self.selected_session_text() + } else { + None + }; + + if let Some(workspace) = self.workspace.as_mut() { + workspace.session_drag_active = false; + } + + if let Some(text) = selection_text.filter(|text| !text.is_empty()) { + super::session::copy_to_clipboard_osc52(&text)?; + } + } + _ => {} + } + + Ok(()) + } + + pub(super) fn handle_workspace_key(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Esc => Ok(true), + KeyCode::Up => { + self.scroll_session_by(-1); + Ok(false) + } + KeyCode::Down => { + self.scroll_session_by(1); + Ok(false) + } + KeyCode::PageUp => { + self.scroll_session_by(-10); + Ok(false) + } + KeyCode::PageDown => { + self.scroll_session_by(10); + Ok(false) + } + KeyCode::Home => { + self.jump_session_to_start(); + Ok(false) + } + KeyCode::End => { + self.follow_session_output(); + Ok(false) + } + KeyCode::Backspace => { + if let Some(workspace) = self.workspace.as_mut() { + workspace.input.pop(); + } + Ok(false) + } + KeyCode::Enter => { + let input = if let Some(workspace) = self.workspace.as_mut() { + let input = workspace.input.trim().to_string(); + workspace.input.clear(); + input + } else { + String::new() + }; + if input.is_empty() { + return Ok(false); + } + self.dispatch_workspace_input(input)?; + Ok(false) + } + KeyCode::Char(ch) => { + if let Some(workspace) = self.workspace.as_mut() { + workspace.input.push(ch); + } + Ok(false) + } + _ => Ok(false), + } + } + + pub(super) fn dispatch_workspace_input(&mut self, input: String) -> Result<()> { + let phase = self + .workspace + .as_ref() + .map(|workspace| workspace.state.phase.clone()) + .ok_or_else(|| anyhow!("workspace is not active"))?; + + if input.starts_with('/') { + if matches!(phase, ControllerPhase::Planning) { + self.push_local_entry( + SessionSource::Warning, + SessionStream::Status, + "Planning", + None, + "Slash commands are only available outside planning.", + ); + return Ok(()); + } + + return self.handle_workspace_command(&input); + } + + self.submit_workspace_input(input) + } + + fn handle_workspace_command(&mut self, input: &str) -> Result<()> { + if self.workspace.is_none() { + return Ok(()); + } + + let local_entry = match input { + "/pause" => { + if let Some(workspace) = self.workspace.as_ref() { + let _ = workspace.control_tx.send(ControlCommand::Pause); + } + Some(( + SessionSource::Controller, + SessionStream::Status, + "Controller", + None, + "Pause requested.".to_string(), + )) + } + "/resume" => { + if let Some(workspace) = self.workspace.as_ref() { + let _ = workspace.control_tx.send(ControlCommand::Resume); + } + Some(( + SessionSource::Controller, + SessionStream::Status, + "Controller", + None, + "Resume requested.".to_string(), + )) + } + "/stop" => { + if let Some(workspace) = self.workspace.as_ref() { + let _ = workspace.control_tx.send(ControlCommand::Stop); + } + Some(( + SessionSource::Warning, + SessionStream::Status, + "Controller", + None, + "Stop requested.".to_string(), + )) + } + "/status" => { + let summary = self + .workspace + .as_ref() + .map(|workspace| { + format!( + "phase={} current_step={} completed={}/{}", + workspace.state.phase.label(), + workspace + .state + .current_step_id + .clone() + .unwrap_or_else(|| "none".to_string()), + workspace.state.completed_steps.len(), + workspace.plan.steps.len() + ) + }) + .unwrap_or_else(|| "No workspace loaded.".to_string()); + Some(( + SessionSource::Controller, + SessionStream::Status, + "Status", + None, + summary, + )) + } + "/diff" => Some(( + SessionSource::Controller, + SessionStream::Status, + "Diff", + None, + "Use git diff in a separate terminal; inline diff view is not implemented." + .to_string(), + )), + "/tests" => { + let summary = self + .workspace + .as_ref() + .and_then(|workspace| workspace.state.last_full_test_summary.as_ref()) + .map(|tests| tests.summary.clone()) + .unwrap_or_else(|| "No test summary recorded yet.".to_string()); + Some(( + SessionSource::Verifier, + SessionStream::Status, + "Tests", + None, + summary, + )) + } + "/goal update" => { + if let Some(workspace) = self.workspace.as_mut() { + workspace.state.phase = ControllerPhase::Planning; + toon::write_state(&workspace.task_config.state_file, &workspace.state)?; + } + Some(( + SessionSource::Controller, + SessionStream::Status, + "Planning", + None, + "Goal update requested. Enter the new goal or answer the next planning question." + .to_string(), + )) + } + _ => Some(( + SessionSource::Warning, + SessionStream::Status, + "Command", + None, + format!("Unknown command: {input}"), + )), + }; + + if let Some((source, stream, title, tag, body)) = local_entry { + self.push_local_entry(source, stream, title, tag, &body); + } + Ok(()) + } + + pub(super) fn submit_workspace_input(&mut self, input: String) -> Result<()> { + let Some(workspace) = self.workspace.as_mut() else { + return Err(anyhow!("workspace is not active")); + }; + + workspace.session_follow_output = true; + workspace.session_selection = None; + workspace.session_drag_active = false; + workspace.session_entries.push(SessionEntry { + source: SessionSource::User, + stream: SessionStream::Status, + title: "You".to_string(), + tag: None, + body: input.clone(), + run_id: repo::next_run_id(), + }); + let _ = workspace.control_tx.send(ControlCommand::Submit(input)); + Ok(()) + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..e828165 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,79 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +use crate::app::App; +use crate::model::TaskConfig; +use crate::storage::toon; + +pub const DEFAULT_TASK_CONFIG_PATH: &str = ".agent/controller-loop/task.toon"; + +#[derive(Debug, Parser)] +#[command(name = "codex-controller-loop")] +#[command(about = "Rust TUI-first autonomous controller with TOON persistence")] +pub struct Cli { + #[arg(long, global = true)] + pub task: Option, + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + Init { + #[arg(long, default_value = "controller-loop")] + task_id: String, + }, + Run, + Status, + Tui, +} + +impl Cli { + pub fn parse_args() -> Self { + Self::parse() + } + + pub fn run(self) -> Result<()> { + match self.command.unwrap_or(Command::Tui) { + Command::Init { task_id } => { + let task_path = self + .task + .unwrap_or_else(|| PathBuf::from(DEFAULT_TASK_CONFIG_PATH)); + let config = TaskConfig::default_for(&task_id); + toon::write_task_config(&task_path, &config)?; + toon::ensure_controller_files(&config)?; + println!("Initialized controller task at {}", task_path.display()); + Ok(()) + } + Command::Run | Command::Tui => { + let mut app = App::bootstrap(self.task)?; + app.run() + } + Command::Status => { + let task_path = self + .task + .unwrap_or_else(|| PathBuf::from(DEFAULT_TASK_CONFIG_PATH)); + let config = toon::read_task_config(&task_path)?; + let plan = toon::read_plan(&config.plan_file)?; + let state = toon::read_state(&config.state_file)?; + println!("phase: {:?}", state.phase); + println!("goal summary: {}", plan.goal_summary); + println!( + "steps: total={} done={} blocked={}", + plan.steps.len(), + plan.steps + .iter() + .filter(|step| step.status.is_done()) + .count(), + plan.steps + .iter() + .filter(|step| step.status.is_blocked()) + .count() + ); + Ok(()) + } + } + } +} diff --git a/src/controller/engine.rs b/src/controller/engine.rs new file mode 100644 index 0000000..50c31e2 --- /dev/null +++ b/src/controller/engine.rs @@ -0,0 +1,206 @@ +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; + +use anyhow::Result; + +use crate::app::{AppEvent, ControlCommand}; +use crate::controller::{executor, goal_checker, planner, verifier}; +use crate::model::{ + ControllerPhase, GoalStatus, SessionEntry, SessionSource, SessionStream, TaskConfig, +}; +use crate::repo; +use crate::storage::toon; + +pub fn runtime_loop( + repo_root: PathBuf, + config: TaskConfig, + control_rx: Receiver, + event_tx: Sender, +) -> Result<()> { + toon::ensure_controller_files(&config)?; + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Controller, + stream: SessionStream::Status, + title: "Session".to_string(), + tag: Some(config.controller_id()), + body: format!("Controller task loaded from {}", config.plan_file.display()), + run_id: repo::next_run_id(), + })); + + loop { + let mut plan = toon::read_plan(&config.plan_file)?; + let mut state = toon::read_state(&config.state_file)?; + let goal_md = toon::read_markdown(&config.goal_file)?; + let standards_md = toon::read_markdown(&config.standards_file)?; + let _ = event_tx.send(AppEvent::Snapshot { + goal_md, + standards_md, + plan: plan.clone(), + state: state.clone(), + }); + + match control_rx.try_recv() { + Ok(ControlCommand::Quit) => break, + Ok(ControlCommand::Pause) => { + crate::controller::state::pause(&mut state); + toon::write_state(&config.state_file, &state)?; + continue; + } + Ok(ControlCommand::Resume) => { + crate::controller::state::resume(&mut state); + toon::write_state(&config.state_file, &state)?; + continue; + } + Ok(ControlCommand::Stop) => { + state.phase = ControllerPhase::Blocked; + state.goal_status = GoalStatus::Blocked; + state.notes.push("Stopped by user".to_string()); + toon::write_state(&config.state_file, &state)?; + continue; + } + Ok(ControlCommand::Submit(text)) => { + if matches!(state.phase, ControllerPhase::Planning) { + let response = + crate::planning::session::advance(&repo_root, &config, &text, &event_tx)?; + if let Some(question) = response.question { + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Planner, + stream: SessionStream::Status, + title: "Question".to_string(), + tag: Some(config.controller_id()), + body: question, + run_id: repo::next_run_id(), + })); + } + } else { + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Warning, + stream: SessionStream::Status, + title: "Execution".to_string(), + tag: Some(config.controller_id()), + body: "Execution is autonomous. Use /pause, /resume, /stop, /status, /diff, /tests, or /goal update." + .to_string(), + run_id: repo::next_run_id(), + })); + } + continue; + } + Err(TryRecvError::Disconnected) => break, + Err(TryRecvError::Empty) => {} + } + + match state.phase { + ControllerPhase::Planning + | ControllerPhase::Paused + | ControllerPhase::Blocked + | ControllerPhase::Done => { + std::thread::sleep(std::time::Duration::from_millis(100)); + continue; + } + ControllerPhase::Executing => {} + } + + if goal_checker::is_done(&plan, &state)? { + state.phase = ControllerPhase::Done; + state.goal_status = GoalStatus::Done; + toon::write_state(&config.state_file, &state)?; + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Controller, + stream: SessionStream::Status, + title: "Goal".to_string(), + tag: Some(config.controller_id()), + body: "Goal complete".to_string(), + run_id: repo::next_run_id(), + })); + continue; + } + + if state.replan_required || plan.has_no_actionable_steps() { + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Planner, + stream: SessionStream::Status, + title: "Planner".to_string(), + tag: Some(config.controller_id()), + body: "Refining plan".to_string(), + run_id: repo::next_run_id(), + })); + plan = + planner::refine_without_user_input(&repo_root, &config, &plan, &state, &event_tx)?; + state.replan_required = false; + toon::write_plan(&config.plan_file, &plan)?; + toon::write_state(&config.state_file, &state)?; + continue; + } + + let Some(step) = planner::next_step(&plan, &state) else { + state.phase = ControllerPhase::Blocked; + state.goal_status = GoalStatus::Blocked; + state.notes.push( + "No actionable step remained and autonomous replan produced nothing.".to_string(), + ); + toon::write_state(&config.state_file, &state)?; + continue; + }; + + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Executor, + stream: SessionStream::Status, + title: "Step".to_string(), + tag: Some(step.id.clone()), + body: format!("Executing {}", step.title), + run_id: repo::next_run_id(), + })); + plan.mark_active(&step.id); + state.current_step_id = Some(step.id.clone()); + state.iteration += 1; + toon::write_plan(&config.plan_file, &plan)?; + toon::write_state(&config.state_file, &state)?; + + let exec = executor::implement(&repo_root, &config, &plan, &step, &event_tx)?; + if goal_checker::needs_goal_clarification(&exec) { + state.phase = ControllerPhase::Planning; + state.notes.push(format!( + "Execution requested goal clarification while processing {}", + step.id + )); + toon::write_state(&config.state_file, &state)?; + continue; + } + + let verification = verifier::verify_step(&repo_root, &exec, &event_tx)?; + if !verification.passed { + plan.mark_blocked(&step.id); + state.last_verification = Some(verification); + state.blocked_steps.push(step.id.clone()); + state.replan_required = true; + toon::write_plan(&config.plan_file, &plan)?; + toon::write_state(&config.state_file, &state)?; + continue; + } + + let cleanup = verifier::verify_cleanup(&config, &step, &exec)?; + if !cleanup.passed { + plan.mark_todo(&step.id); + state.last_cleanup_summary = Some(cleanup); + toon::write_plan(&config.plan_file, &plan)?; + toon::write_state(&config.state_file, &state)?; + continue; + } + + let tests = verifier::run_tests(&repo_root, &exec, &event_tx)?; + if !tests.passed { + plan.mark_todo(&step.id); + state.last_full_test_summary = Some(tests); + toon::write_plan(&config.plan_file, &plan)?; + toon::write_state(&config.state_file, &state)?; + continue; + } + + plan.mark_done(&step.id); + state.complete_step(&step, verification, cleanup, tests); + toon::write_plan(&config.plan_file, &plan)?; + toon::write_state(&config.state_file, &state)?; + } + + Ok(()) +} diff --git a/src/controller/executor.rs b/src/controller/executor.rs new file mode 100644 index 0000000..6a68d2f --- /dev/null +++ b/src/controller/executor.rs @@ -0,0 +1,61 @@ +use std::sync::mpsc::Sender; + +use anyhow::Result; +use serde_json::json; + +use crate::app::AppEvent; +use crate::model::{ExecutionResponse, Plan, PlanStep, SessionSource, TaskConfig}; +use crate::process; +use crate::storage::toon; + +pub fn implement( + repo_root: &std::path::Path, + config: &TaskConfig, + plan: &Plan, + step: &PlanStep, + event_tx: &Sender, +) -> Result { + let goal_md = toon::read_markdown(&config.goal_file)?; + let standards_md = toon::read_markdown(&config.standards_file)?; + let prompt = format!( + concat!( + "You are the autonomous execution worker for a Rust TUI-first controller.\n", + "You are in execution mode. Do not ask the user questions.\n", + "Implement the step, verify it, clean up the implementation, and leave the codebase in maintainable condition.\n", + "You may edit files in the repository. You own the implementation decisions.\n", + "If the goal itself is ambiguous, set needs_goal_clarification=true.\n", + "Return verification commands and test commands that the controller should run after your work.\n\n", + "Goal:\n{goal}\n\n", + "Standards:\n{standards}\n\n", + "Current plan:\n{plan}\n\n", + "Active step:\n{step}\n" + ), + goal = goal_md, + standards = standards_md, + plan = serde_json::to_string_pretty(plan)?, + step = serde_json::to_string_pretty(step)?, + ); + + let schema = json!({ + "type": "object", + "additionalProperties": false, + "required": ["status", "summary", "verification_commands", "test_commands", "notes", "needs_goal_clarification"], + "properties": { + "status": { "type": "string", "enum": ["done", "blocked", "needs-replan"] }, + "summary": { "type": "string" }, + "verification_commands": { "type": "array", "items": { "type": "string" } }, + "test_commands": { "type": "array", "items": { "type": "string" } }, + "notes": { "type": "array", "items": { "type": "string" } }, + "needs_goal_clarification": { "type": "boolean" } + } + }); + let raw = process::run_codex_with_schema( + repo_root, + &prompt, + &schema, + event_tx, + SessionSource::Executor, + Some(step.id.clone()), + )?; + Ok(serde_json::from_str(&raw)?) +} diff --git a/src/controller/goal_checker.rs b/src/controller/goal_checker.rs new file mode 100644 index 0000000..d443229 --- /dev/null +++ b/src/controller/goal_checker.rs @@ -0,0 +1,13 @@ +use anyhow::Result; + +use crate::model::{ControllerState, GoalStatus, Plan}; + +pub fn is_done(plan: &Plan, state: &ControllerState) -> Result { + Ok(!plan.steps.is_empty() + && plan.steps.iter().all(|step| step.status.is_done()) + && !matches!(state.goal_status, GoalStatus::Blocked)) +} + +pub fn needs_goal_clarification(response: &crate::model::ExecutionResponse) -> bool { + response.needs_goal_clarification +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs new file mode 100644 index 0000000..d11e2e9 --- /dev/null +++ b/src/controller/mod.rs @@ -0,0 +1,6 @@ +pub mod engine; +pub mod executor; +pub mod goal_checker; +pub mod planner; +pub mod state; +pub mod verifier; diff --git a/src/controller/planner.rs b/src/controller/planner.rs new file mode 100644 index 0000000..d078026 --- /dev/null +++ b/src/controller/planner.rs @@ -0,0 +1,50 @@ +use std::sync::mpsc::Sender; + +use anyhow::Result; + +use crate::app::AppEvent; +use crate::model::{self, ControllerState, Plan, SessionSource, TaskConfig}; +use crate::process; +use crate::storage::toon; + +pub fn refine_without_user_input( + repo_root: &std::path::Path, + config: &TaskConfig, + plan: &Plan, + state: &ControllerState, + event_tx: &Sender, +) -> Result { + let goal_md = toon::read_markdown(&config.goal_file)?; + let standards_md = toon::read_markdown(&config.standards_file)?; + let prompt = format!( + concat!( + "You are refining the execution plan for an autonomous coding controller.\n", + "Do not ask the user any questions.\n", + "Use the goal, standards, current plan, and state to produce the next complete plan.\n", + "Keep existing stable step ids when still valid.\n", + "Return only the plan object.\n\n", + "Goal:\n{goal}\n\n", + "Standards:\n{standards}\n\n", + "Current plan:\n{plan}\n\n", + "Current state:\n{state}\n" + ), + goal = goal_md, + standards = standards_md, + plan = serde_json::to_string_pretty(plan)?, + state = serde_json::to_string_pretty(state)?, + ); + let schema = model::plan_schema(); + let raw = process::run_codex_with_schema( + repo_root, + &prompt, + &schema, + event_tx, + SessionSource::Planner, + Some(config.controller_id()), + )?; + Ok(serde_json::from_str(&raw)?) +} + +pub fn next_step(plan: &Plan, state: &ControllerState) -> Option { + plan.next_actionable_step(&state.completed_steps) +} diff --git a/src/controller/state.rs b/src/controller/state.rs new file mode 100644 index 0000000..64a300a --- /dev/null +++ b/src/controller/state.rs @@ -0,0 +1,9 @@ +use crate::model::{ControllerPhase, ControllerState}; + +pub fn pause(state: &mut ControllerState) { + state.phase = ControllerPhase::Paused; +} + +pub fn resume(state: &mut ControllerState) { + state.phase = ControllerPhase::Executing; +} diff --git a/src/controller/verifier.rs b/src/controller/verifier.rs new file mode 100644 index 0000000..3f0778a --- /dev/null +++ b/src/controller/verifier.rs @@ -0,0 +1,44 @@ +use std::sync::mpsc::Sender; + +use anyhow::Result; + +use crate::app::AppEvent; +use crate::model::{ + CleanupSummary, ExecutionResponse, PlanStep, TaskConfig, TestSummary, VerificationSummary, +}; +use crate::process; + +pub fn verify_step( + repo_root: &std::path::Path, + response: &ExecutionResponse, + event_tx: &Sender, +) -> Result { + process::run_shell_commands( + repo_root, + &response.verification_commands, + event_tx, + "Verification", + None, + ) +} + +pub fn verify_cleanup( + _config: &TaskConfig, + step: &PlanStep, + response: &ExecutionResponse, +) -> Result { + Ok(CleanupSummary { + passed: !response.summary.trim().is_empty(), + summary: format!("Cleanup accepted for {}", step.id), + commands: Vec::new(), + output: response.notes.clone(), + }) +} + +pub fn run_tests( + repo_root: &std::path::Path, + response: &ExecutionResponse, + event_tx: &Sender, +) -> Result { + process::run_shell_commands(repo_root, &response.test_commands, event_tx, "Tests", None) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..463eae1 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,7 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ControllerError { + #[error("command failed: {0}")] + CommandFailed(String), +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b00847c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,17 @@ +mod app; +mod cli; +mod controller; +mod error; +mod model; +mod planning; +mod process; +mod repo; +mod storage; +mod ui; + +use anyhow::Result; + +fn main() -> Result<()> { + let cli = cli::Cli::parse_args(); + cli.run() +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..34daf42 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,774 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + ControllerPicker, + CreateController, + Workspace, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ContinueUntil { + FixedPoint, + GreenRoot, + ManualStop, +} + +impl Default for ContinueUntil { + fn default() -> Self { + Self::FixedPoint + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ControllerPhase { + Planning, + Executing, + Paused, + Blocked, + Done, +} + +impl ControllerPhase { + pub fn label(&self) -> &'static str { + match self { + Self::Planning => "Planning", + Self::Executing => "Executing", + Self::Paused => "Paused", + Self::Blocked => "Blocked", + Self::Done => "Done", + } + } +} + +impl Default for ControllerPhase { + fn default() -> Self { + Self::Planning + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GoalStatus { + Unknown, + InProgress, + Done, + Blocked, +} + +impl Default for GoalStatus { + fn default() -> Self { + Self::Unknown + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum StepStatus { + Todo, + Active, + Done, + Blocked, +} + +impl StepStatus { + pub fn is_done(&self) -> bool { + matches!(self, Self::Done) + } + + pub fn is_blocked(&self) -> bool { + matches!(self, Self::Blocked) + } + + pub fn marker(&self) -> &'static str { + match self { + Self::Todo => "[ ]", + Self::Active => "[>]", + Self::Done => "[x]", + Self::Blocked => "[-]", + } + } +} + +impl Default for StepStatus { + fn default() -> Self { + Self::Todo + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskConfig { + pub engine: String, + pub goal_file: PathBuf, + pub plan_file: PathBuf, + pub state_file: PathBuf, + pub standards_file: PathBuf, + pub branch: String, + pub continue_until: ContinueUntil, + pub max_runs: u32, + pub max_wall_clock: String, +} + +impl TaskConfig { + pub fn default_for(task_id: &str) -> Self { + let root = PathBuf::from(format!(".agent/controllers/{task_id}")); + Self { + engine: "data-driven-v1".to_string(), + goal_file: root.join("goal.md"), + plan_file: root.join("plan.toon"), + state_file: root.join("state.toon"), + standards_file: root.join("standards.md"), + branch: format!("codex/{task_id}"), + continue_until: ContinueUntil::FixedPoint, + max_runs: 12, + max_wall_clock: "4h".to_string(), + } + } + + pub fn controller_id(&self) -> String { + self.goal_file + .parent() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .unwrap_or("controller-loop") + .to_string() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VerificationCheck { + pub label: String, + pub commands: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CleanupRule { + pub label: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PlanStep { + pub id: String, + pub title: String, + pub purpose: String, + pub inputs: Vec, + pub outputs: Vec, + pub dependencies: Vec, + pub verification: Vec, + pub cleanup_requirements: Vec, + pub status: StepStatus, + pub attempts: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Plan { + pub version: u32, + pub goal_summary: String, + pub steps: Vec, +} + +impl Default for Plan { + fn default() -> Self { + Self { + version: 1, + goal_summary: "No plan yet".to_string(), + steps: Vec::new(), + } + } +} + +impl Plan { + pub fn has_no_actionable_steps(&self) -> bool { + self.steps + .iter() + .all(|step| !matches!(step.status, StepStatus::Todo)) + } + + pub fn next_actionable_step(&self, completed_steps: &[String]) -> Option { + self.steps + .iter() + .find(|step| { + matches!(step.status, StepStatus::Todo) + && step + .dependencies + .iter() + .all(|dependency| completed_steps.iter().any(|done| done == dependency)) + }) + .cloned() + } + + pub fn mark_active(&mut self, step_id: &str) { + self.set_status(step_id, StepStatus::Active); + } + + pub fn mark_todo(&mut self, step_id: &str) { + self.set_status(step_id, StepStatus::Todo); + } + + pub fn mark_done(&mut self, step_id: &str) { + self.set_status(step_id, StepStatus::Done); + } + + pub fn mark_blocked(&mut self, step_id: &str) { + self.set_status(step_id, StepStatus::Blocked); + } + + pub fn current_step_title(&self, step_id: Option<&str>) -> Option { + let step_id = step_id?; + self.steps + .iter() + .find(|step| step.id == step_id) + .map(|step| step.title.clone()) + } + + fn set_status(&mut self, step_id: &str, status: StepStatus) { + for step in &mut self.steps { + if step.id == step_id { + step.status = status.clone(); + if matches!(status, StepStatus::Active) { + step.attempts += 1; + } + } else if matches!(status, StepStatus::Active) + && matches!(step.status, StepStatus::Active) + { + step.status = StepStatus::Todo; + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CommandSummary { + pub passed: bool, + pub summary: String, + pub commands: Vec, + pub output: Vec, +} + +pub type VerificationSummary = CommandSummary; +pub type CleanupSummary = CommandSummary; +pub type TestSummary = CommandSummary; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HistoryEvent { + pub timestamp: String, + pub kind: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PlanningTurn { + pub role: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PlanningSessionMeta { + pub pending_question: Option, + pub transcript: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ControllerState { + pub version: u32, + pub phase: ControllerPhase, + pub goal_status: GoalStatus, + pub goal_revision: u32, + pub current_step_id: Option, + pub iteration: u32, + pub replan_required: bool, + pub completed_steps: Vec, + pub blocked_steps: Vec, + pub last_verification: Option, + pub last_cleanup_summary: Option, + pub last_full_test_summary: Option, + pub history: Vec, + pub notes: Vec, + pub planning_session: PlanningSessionMeta, + pub started_at: Option, + pub last_usage_refresh_at: Option, + pub last_usage_input_tokens: Option, + pub last_usage_output_tokens: Option, +} + +impl Default for ControllerState { + fn default() -> Self { + Self { + version: 1, + phase: ControllerPhase::Planning, + goal_status: GoalStatus::Unknown, + goal_revision: 0, + current_step_id: None, + iteration: 0, + replan_required: false, + completed_steps: Vec::new(), + blocked_steps: Vec::new(), + last_verification: None, + last_cleanup_summary: None, + last_full_test_summary: None, + history: Vec::new(), + notes: Vec::new(), + planning_session: PlanningSessionMeta::default(), + started_at: None, + last_usage_refresh_at: None, + last_usage_input_tokens: None, + last_usage_output_tokens: None, + } + } +} + +impl ControllerState { + pub fn complete_step( + &mut self, + step: &PlanStep, + verification: VerificationSummary, + cleanup: CleanupSummary, + tests: TestSummary, + ) { + self.current_step_id = None; + self.goal_status = GoalStatus::InProgress; + self.completed_steps.push(step.id.clone()); + self.replan_required = true; + self.last_verification = Some(verification); + self.last_cleanup_summary = Some(cleanup); + self.last_full_test_summary = Some(tests); + self.history.push(HistoryEvent { + timestamp: crate::repo::now_timestamp(), + kind: "step-complete".to_string(), + detail: format!("Completed {}", step.id), + }); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlannerResponse { + pub kind: String, + pub question: Option, + pub goal_md: Option, + pub standards_md: Option, + pub plan: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ExecutionResponse { + pub status: String, + pub summary: String, + pub verification_commands: Vec, + pub test_commands: Vec, + pub notes: Vec, + pub needs_goal_clarification: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControllerSummary { + pub id: String, + pub goal_summary: String, + pub phase: ControllerPhase, + pub current_step_id: Option, + pub completed_steps: usize, + pub total_steps: usize, + pub last_updated: Option, + pub branch: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionSource { + User, + Controller, + Planner, + Executor, + Verifier, + Warning, +} + +impl SessionSource { + pub fn label(&self) -> &'static str { + match self { + Self::User => "User", + Self::Controller => "Controller", + Self::Planner => "Planner", + Self::Executor => "Executor", + Self::Verifier => "Verifier", + Self::Warning => "Warning", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionStream { + Status, + Stdout, + Stderr, +} + +impl SessionStream { + pub fn label(&self) -> &'static str { + match self { + Self::Status => "status", + Self::Stdout => "stdout", + Self::Stderr => "stderr", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionEntry { + pub source: SessionSource, + pub stream: SessionStream, + pub title: String, + pub tag: Option, + pub body: String, + pub run_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionGroup { + pub source: SessionSource, + pub stream: SessionStream, + pub title: String, + pub tag: Option, + pub lines: Vec, + pub run_id: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionCursor { + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionSelection { + pub anchor: SessionCursor, + pub focus: SessionCursor, +} + +impl SessionSelection { + pub fn ordered(&self) -> (SessionCursor, SessionCursor) { + if (self.anchor.line, self.anchor.column) <= (self.focus.line, self.focus.column) { + (self.anchor, self.focus) + } else { + (self.focus, self.anchor) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsageSnapshot { + pub input_tokens: Option, + pub output_tokens: Option, + pub refreshed_at: Option, + pub available: bool, + pub note: Option, +} + +impl Default for UsageSnapshot { + fn default() -> Self { + Self::unavailable("usage unavailable") + } +} + +impl UsageSnapshot { + pub fn unavailable(note: impl Into) -> Self { + Self { + input_tokens: None, + output_tokens: None, + refreshed_at: Some(crate::repo::now_timestamp()), + available: false, + note: Some(note.into()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusSnapshot { + pub controller_id: String, + pub branch: String, + pub started_at: Option, + pub phase: ControllerPhase, + pub iteration: u32, + pub session_input_tokens: Option, + pub session_output_tokens: Option, + pub usage: UsageSnapshot, +} + +pub fn group_session_entries(entries: &[SessionEntry]) -> Vec { + let mut groups: Vec = Vec::new(); + + for entry in entries { + let body_lines = if entry.body.is_empty() { + vec![String::new()] + } else { + entry + .body + .lines() + .map(ToString::to_string) + .collect::>() + }; + + if let Some(last) = groups.last_mut() { + if last.source == entry.source + && last.stream == entry.stream + && last.title == entry.title + && last.tag == entry.tag + && last.run_id == entry.run_id + { + last.lines.extend(body_lines); + continue; + } + } + + groups.push(SessionGroup { + source: entry.source, + stream: entry.stream, + title: entry.title.clone(), + tag: entry.tag.clone(), + lines: body_lines, + run_id: entry.run_id, + }); + } + + groups +} + +pub fn verification_check_schema() -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["label", "commands"], + "properties": { + "label": { "type": "string" }, + "commands": { + "type": "array", + "items": { "type": "string" } + } + } + }) +} + +pub fn cleanup_rule_schema() -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["label", "description"], + "properties": { + "label": { "type": "string" }, + "description": { "type": "string" } + } + }) +} + +pub fn plan_step_schema() -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "purpose", + "inputs", + "outputs", + "dependencies", + "verification", + "cleanup_requirements", + "status", + "attempts" + ], + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "purpose": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } }, + "dependencies": { "type": "array", "items": { "type": "string" } }, + "verification": { + "type": "array", + "items": verification_check_schema() + }, + "cleanup_requirements": { + "type": "array", + "items": cleanup_rule_schema() + }, + "status": { + "type": "string", + "enum": ["todo", "active", "done", "blocked"] + }, + "attempts": { "type": "integer" } + } + }) +} + +pub fn plan_schema() -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["version", "goal_summary", "steps"], + "properties": { + "version": { "type": "integer" }, + "goal_summary": { "type": "string" }, + "steps": { + "type": "array", + "items": plan_step_schema() + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_actionable_step_respects_dependencies() { + let mut plan = Plan { + version: 1, + goal_summary: "goal".to_string(), + steps: vec![ + PlanStep { + id: "a".to_string(), + title: "A".to_string(), + status: StepStatus::Todo, + ..PlanStep::default() + }, + PlanStep { + id: "b".to_string(), + title: "B".to_string(), + dependencies: vec!["a".to_string()], + status: StepStatus::Todo, + ..PlanStep::default() + }, + ], + }; + + assert_eq!( + plan.next_actionable_step(&[]) + .expect("expected first step") + .id, + "a" + ); + plan.mark_done("a"); + assert_eq!( + plan.next_actionable_step(&["a".to_string()]) + .expect("expected second step") + .id, + "b" + ); + } + + #[test] + fn mark_active_increments_attempts() { + let mut plan = Plan { + version: 1, + goal_summary: "goal".to_string(), + steps: vec![PlanStep { + id: "a".to_string(), + title: "A".to_string(), + status: StepStatus::Todo, + ..PlanStep::default() + }], + }; + + plan.mark_active("a"); + assert_eq!(plan.steps[0].attempts, 1); + assert!(matches!(plan.steps[0].status, StepStatus::Active)); + } + + #[test] + fn complete_step_records_history() { + let mut state = ControllerState::default(); + let step = PlanStep { + id: "a".to_string(), + title: "A".to_string(), + ..PlanStep::default() + }; + + state.complete_step( + &step, + VerificationSummary { + passed: true, + summary: "ok".to_string(), + commands: vec![], + output: vec![], + }, + CleanupSummary { + passed: true, + summary: "ok".to_string(), + commands: vec![], + output: vec![], + }, + TestSummary { + passed: true, + summary: "ok".to_string(), + commands: vec![], + output: vec![], + }, + ); + + assert_eq!(state.completed_steps, vec!["a".to_string()]); + assert!(state.replan_required); + assert_eq!(state.history.len(), 1); + } + + #[test] + fn groups_consecutive_session_entries() { + let groups = group_session_entries(&[ + SessionEntry { + source: SessionSource::Planner, + stream: SessionStream::Stdout, + title: "Thought".to_string(), + tag: Some("plan".to_string()), + body: "first line".to_string(), + run_id: 1, + }, + SessionEntry { + source: SessionSource::Planner, + stream: SessionStream::Stdout, + title: "Thought".to_string(), + tag: Some("plan".to_string()), + body: "second line".to_string(), + run_id: 1, + }, + SessionEntry { + source: SessionSource::Planner, + stream: SessionStream::Stderr, + title: "Thought".to_string(), + tag: Some("plan".to_string()), + body: "warning".to_string(), + run_id: 1, + }, + ]); + + assert_eq!(groups.len(), 2); + assert_eq!( + groups[0].lines, + vec!["first line".to_string(), "second line".to_string()] + ); + assert_eq!(groups[1].stream, SessionStream::Stderr); + } + + #[test] + fn plan_schema_locks_down_nested_objects() { + let schema = plan_schema(); + assert_eq!(schema["additionalProperties"], false); + assert_eq!( + schema["properties"]["steps"]["items"]["additionalProperties"], + false + ); + assert_eq!( + schema["properties"]["steps"]["items"]["properties"]["verification"]["items"] + ["additionalProperties"], + false + ); + assert_eq!( + schema["properties"]["steps"]["items"]["properties"]["cleanup_requirements"]["items"] + ["additionalProperties"], + false + ); + } +} diff --git a/src/planning/forwarder.rs b/src/planning/forwarder.rs new file mode 100644 index 0000000..e141fc4 --- /dev/null +++ b/src/planning/forwarder.rs @@ -0,0 +1,98 @@ +use serde_json::json; + +use crate::model::{self, ControllerState, PlannerResponse, TaskConfig}; + +pub fn planning_schema() -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["kind", "question", "goal_md", "standards_md", "plan"], + "properties": { + "kind": { "type": "string", "enum": ["question", "final"] }, + "question": { "type": ["string", "null"] }, + "goal_md": { "type": ["string", "null"] }, + "standards_md": { "type": ["string", "null"] }, + "plan": { + "anyOf": [ + model::plan_schema(), + { "type": "null" } + ] + } + } + }) +} + +pub fn build_planning_prompt( + config: &TaskConfig, + goal_md: &str, + standards_md: &str, + state: &ControllerState, + latest_user_input: &str, +) -> String { + let transcript = state + .planning_session + .transcript + .iter() + .map(|turn| format!("{}: {}", turn.role, turn.content)) + .collect::>() + .join("\n"); + + format!( + concat!( + "You are embedded Codex planning mode for a Rust autonomous controller.\n", + "You are only handling the planning phase.\n\n", + "Rules:\n", + "- Ask at most one follow-up question if the goal is still ambiguous.\n", + "- If you have enough information, return kind=final.\n", + "- Always include all response keys.\n", + "- Use null for any field that does not apply in the current response.\n", + "- The final plan must be decision-complete for autonomous execution.\n", + "- The plan should be maintainable and production-quality.\n", + "- The controller directory contains only Markdown and TOON files.\n\n", + "Task config paths:\n", + "- goal: {goal}\n", + "- plan: {plan}\n", + "- state: {state}\n", + "- standards: {standards}\n\n", + "Current goal markdown:\n{goal_md}\n\n", + "Current standards markdown:\n{standards_md}\n\n", + "Transcript so far:\n{transcript}\n\n", + "Latest user input:\n{latest}\n\n", + "When returning kind=final, include:\n", + "- goal_md: rewritten goal markdown\n", + "- standards_md: rewritten standards markdown\n", + "- plan: structured machine-readable plan object with ordered steps, verification, cleanup requirements, and statuses.\n" + ), + goal = config.goal_file.display(), + plan = config.plan_file.display(), + state = config.state_file.display(), + standards = config.standards_file.display(), + goal_md = goal_md, + standards_md = standards_md, + transcript = transcript, + latest = latest_user_input, + ) +} + +pub fn parse_planning_response(raw: &str) -> anyhow::Result { + Ok(serde_json::from_str(raw)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn planning_schema_requires_all_declared_keys() { + let schema = planning_schema(); + assert_eq!( + schema["required"], + json!(["kind", "question", "goal_md", "standards_md", "plan"]) + ); + assert_eq!( + schema["properties"]["question"]["type"], + json!(["string", "null"]) + ); + assert!(schema["properties"]["plan"]["anyOf"].is_array()); + } +} diff --git a/src/planning/mod.rs b/src/planning/mod.rs new file mode 100644 index 0000000..3ef23a5 --- /dev/null +++ b/src/planning/mod.rs @@ -0,0 +1,2 @@ +pub mod forwarder; +pub mod session; diff --git a/src/planning/session.rs b/src/planning/session.rs new file mode 100644 index 0000000..7906021 --- /dev/null +++ b/src/planning/session.rs @@ -0,0 +1,79 @@ +use std::sync::mpsc::Sender; + +use anyhow::Result; + +use crate::app::AppEvent; +use crate::model::{ControllerPhase, PlannerResponse, PlanningTurn, SessionSource, TaskConfig}; +use crate::process; +use crate::storage::toon; + +pub fn advance( + repo_root: &std::path::Path, + config: &TaskConfig, + latest_user_input: &str, + event_tx: &Sender, +) -> Result { + let mut state = toon::read_state(&config.state_file)?; + let goal_md = toon::read_markdown(&config.goal_file)?; + let standards_md = toon::read_markdown(&config.standards_file)?; + + state.phase = ControllerPhase::Planning; + state.planning_session.transcript.push(PlanningTurn { + role: "user".to_string(), + content: latest_user_input.to_string(), + }); + toon::write_state(&config.state_file, &state)?; + + let prompt = crate::planning::forwarder::build_planning_prompt( + config, + &goal_md, + &standards_md, + &state, + latest_user_input, + ); + let raw = process::run_codex_with_schema( + repo_root, + &prompt, + &crate::planning::forwarder::planning_schema(), + event_tx, + SessionSource::Planner, + Some(config.controller_id()), + )?; + let response = crate::planning::forwarder::parse_planning_response(&raw)?; + + match response.kind.as_str() { + "question" => { + if let Some(question) = &response.question { + state.planning_session.pending_question = Some(question.clone()); + state.planning_session.transcript.push(PlanningTurn { + role: "assistant".to_string(), + content: question.clone(), + }); + toon::write_state(&config.state_file, &state)?; + } + } + "final" => { + let next_goal = response.goal_md.clone().unwrap_or(goal_md); + let next_standards = response.standards_md.clone().unwrap_or(standards_md); + let plan = response.plan.clone().unwrap_or_default(); + + toon::write_markdown(&config.goal_file, &next_goal)?; + toon::write_markdown(&config.standards_file, &next_standards)?; + toon::write_plan(&config.plan_file, &plan)?; + + state.phase = ControllerPhase::Executing; + state.goal_revision += 1; + state.goal_status = crate::model::GoalStatus::InProgress; + state.replan_required = false; + state.planning_session.pending_question = None; + state.planning_session.transcript.push(PlanningTurn { + role: "assistant".to_string(), + content: "Planning completed".to_string(), + }); + toon::write_state(&config.state_file, &state)?; + } + _ => {} + } + + Ok(response) +} diff --git a/src/process.rs b/src/process.rs new file mode 100644 index 0000000..61bf387 --- /dev/null +++ b/src/process.rs @@ -0,0 +1,604 @@ +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::mpsc::Sender; +use std::thread; + +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use tempfile::NamedTempFile; + +use crate::app::AppEvent; +use crate::error::ControllerError; +use crate::model::{ + CommandSummary, ControllerState, SessionEntry, SessionSource, SessionStream, UsageSnapshot, +}; +use crate::repo; + +pub fn run_codex_with_schema( + repo_root: &Path, + prompt: &str, + schema: &Value, + event_tx: &Sender, + source: SessionSource, + tag: Option, +) -> Result { + let mut schema_file = NamedTempFile::new()?; + let output_file = NamedTempFile::new()?; + + std::io::Write::write_all( + &mut schema_file, + serde_json::to_string_pretty(schema)?.as_bytes(), + )?; + + let run_id = repo::next_run_id(); + let mut child = Command::new("codex") + .arg("exec") + .arg("--json") + .arg("-C") + .arg(repo_root) + .arg("--full-auto") + .arg("--color") + .arg("never") + .arg("--output-schema") + .arg(schema_file.path()) + .arg("-o") + .arg(output_file.path()) + .arg(prompt) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn codex exec")?; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_tx = event_tx.clone(); + let stderr_tx = event_tx.clone(); + let stdout_tag = tag.clone(); + let stderr_tag = tag.clone(); + + let stdout_handle = stdout.map(|pipe| { + thread::spawn(move || -> (u64, u64) { + let reader = BufReader::new(pipe); + let mut input_tokens = 0; + let mut output_tokens = 0; + for line in reader.lines().map_while(std::result::Result::ok) { + let parsed = parse_codex_line(&line); + if !parsed.display.is_empty() { + let _ = stdout_tx.send(AppEvent::Session(SessionEntry { + source, + stream: SessionStream::Stdout, + title: parsed.title, + tag: stdout_tag.clone(), + body: parsed.display, + run_id, + })); + } + input_tokens += parsed.input_tokens; + output_tokens += parsed.output_tokens; + } + (input_tokens, output_tokens) + }) + }); + + let stderr_handle = stderr.map(|pipe| { + thread::spawn(move || { + let reader = BufReader::new(pipe); + for line in reader.lines().map_while(std::result::Result::ok) { + if should_ignore_codex_stderr(&line) { + continue; + } + let _ = stderr_tx.send(AppEvent::Session(SessionEntry { + source, + stream: SessionStream::Stderr, + title: "Output".to_string(), + tag: stderr_tag.clone(), + body: line, + run_id, + })); + } + }) + }); + + let status = child.wait()?; + let (input_tokens, output_tokens) = stdout_handle + .map(|handle| handle.join().unwrap_or((0, 0))) + .unwrap_or((0, 0)); + if let Some(handle) = stderr_handle { + let _ = handle.join(); + } + + if input_tokens > 0 || output_tokens > 0 { + let _ = event_tx.send(AppEvent::CodexUsage { + input_tokens, + output_tokens, + }); + } + + if !status.success() { + return Err( + ControllerError::CommandFailed(format!("codex exec exited with {status}")).into(), + ); + } + + std::fs::read_to_string(output_file.path()).context("failed to read codex output schema file") +} + +pub fn generate_controller_id(repo_root: &Path, goal: &str) -> Result { + let schema = json!({ + "type": "object", + "required": ["id"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 3, + "maxLength": 48 + } + } + }); + let prompt = format!( + "Generate a concise controller id for this coding task.\n\ + Return only JSON that matches the schema.\n\ + Constraints:\n\ + - Synthesize a fresh durable name; do not slugify or copy the prompt.\n\ + - Use lowercase kebab-case.\n\ + - Prefer 2 to 4 short words.\n\ + - Avoid generic ids like controller-loop, task, work, or fix.\n\ + - Avoid timestamps unless absolutely necessary.\n\ + Goal:\n{goal}" + ); + + let mut schema_file = NamedTempFile::new()?; + let output_file = NamedTempFile::new()?; + std::io::Write::write_all( + &mut schema_file, + serde_json::to_string_pretty(&schema)?.as_bytes(), + )?; + + let output = Command::new("codex") + .arg("exec") + .arg("--json") + .arg("-C") + .arg(repo_root) + .arg("--sandbox") + .arg("read-only") + .arg("--model") + .arg("gpt-5.4-mini") + .arg("--color") + .arg("never") + .arg("--output-schema") + .arg(schema_file.path()) + .arg("-o") + .arg(output_file.path()) + .arg(prompt) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .context("failed to spawn codex exec for controller id generation")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(ControllerError::CommandFailed(if stderr.is_empty() { + format!("codex exec exited with {}", output.status) + } else { + format!("codex exec exited with {}: {stderr}", output.status) + }) + .into()); + } + + let response = std::fs::read_to_string(output_file.path()) + .context("failed to read controller id output file")?; + let value: Value = + serde_json::from_str(&response).context("failed to parse controller id JSON")?; + value + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .map(ToString::to_string) + .filter(|id| !id.is_empty()) + .context("controller id response did not include a usable id") +} + +pub fn run_shell_commands( + repo_root: &Path, + commands: &[String], + event_tx: &Sender, + title: &str, + tag: Option, +) -> Result { + let run_id = repo::next_run_id(); + let mut output = Vec::new(); + let mut passed = true; + + for command in commands { + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Verifier, + stream: SessionStream::Status, + title: title.to_string(), + tag: tag.clone(), + body: command.clone(), + run_id, + })); + let result = Command::new("zsh") + .arg("-lc") + .arg(command) + .current_dir(repo_root) + .output() + .with_context(|| format!("failed to execute shell command: {command}"))?; + + let stdout = String::from_utf8_lossy(&result.stdout).trim().to_string(); + if !stdout.is_empty() { + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Verifier, + stream: SessionStream::Stdout, + title: title.to_string(), + tag: tag.clone(), + body: stdout.clone(), + run_id, + })); + output.push(stdout); + } + + let stderr = String::from_utf8_lossy(&result.stderr).trim().to_string(); + if !stderr.is_empty() { + let _ = event_tx.send(AppEvent::Session(SessionEntry { + source: SessionSource::Verifier, + stream: SessionStream::Stderr, + title: title.to_string(), + tag: tag.clone(), + body: stderr.clone(), + run_id, + })); + output.push(stderr); + } + + if !result.status.success() { + passed = false; + break; + } + } + + Ok(CommandSummary { + passed, + summary: if commands.is_empty() { + "No commands requested".to_string() + } else if passed { + "All commands passed".to_string() + } else { + "One or more commands failed".to_string() + }, + commands: commands.to_vec(), + output, + }) +} + +pub fn refresh_usage_snapshot(state: &ControllerState) -> UsageSnapshot { + if state.last_usage_input_tokens.is_some() || state.last_usage_output_tokens.is_some() { + UsageSnapshot { + input_tokens: state.last_usage_input_tokens, + output_tokens: state.last_usage_output_tokens, + refreshed_at: Some(repo::now_timestamp()), + available: true, + note: Some("cached snapshot".to_string()), + } + } else { + UsageSnapshot::unavailable("codex usage unavailable") + } +} + +struct ParsedCodexLine { + title: String, + display: String, + input_tokens: u64, + output_tokens: u64, +} + +fn parse_codex_line(line: &str) -> ParsedCodexLine { + let Ok(value) = serde_json::from_str::(line) else { + return ParsedCodexLine { + title: "Output".to_string(), + display: line.to_string(), + input_tokens: 0, + output_tokens: 0, + }; + }; + + let (title, display) = parse_codex_event(&value).unwrap_or_else(|| { + ( + value + .get("type") + .and_then(Value::as_str) + .map(humanize_type) + .unwrap_or_else(|| "Output".to_string()), + first_text(&value).unwrap_or_else(|| line.to_string()), + ) + }); + + ParsedCodexLine { + title, + display, + input_tokens: collect_usage_tokens(&value, true), + output_tokens: collect_usage_tokens(&value, false), + } +} + +fn parse_codex_event(value: &Value) -> Option<(String, String)> { + let event_type = value + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + match event_type { + "thread.started" | "turn.started" | "turn.completed" => { + Some(("Status".to_string(), String::new())) + } + "item.started" | "item.completed" => value + .get("item") + .and_then(|item| parse_codex_item(event_type, item)), + _ => { + let display = first_text(value)?; + Some((humanize_type(event_type), display)) + } + } +} + +fn parse_codex_item(event_type: &str, item: &Value) -> Option<(String, String)> { + let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default(); + let title = classify_codex_item(item_type, item); + let display = match title.as_str() { + "Thinking" => item + .get("text") + .and_then(Value::as_str) + .map(ToString::to_string) + .or_else(|| first_text(item)), + "Command" => render_command_event(event_type, item), + "Patch" | "MCP" | "Plugin" => render_tool_event(event_type, item), + _ => first_text(item), + }?; + let display = display.trim().to_string(); + (!display.is_empty()).then_some((title, display)) +} + +fn classify_codex_item(item_type: &str, item: &Value) -> String { + let item_type = item_type.to_ascii_lowercase(); + let item_name = codex_item_name(item) + .unwrap_or_default() + .to_ascii_lowercase(); + + if item_type == "agent_message" + || item_type.contains("reasoning") + || item_type.contains("thought") + { + return "Thinking".to_string(); + } + + if item_name.contains("apply_patch") || item_type.contains("patch") { + return "Patch".to_string(); + } + + if item_type.contains("command") || item.get("command").is_some() { + return "Command".to_string(); + } + + if item_name.starts_with("mcp__") + || item_type.contains("mcp") + || item.get("server").is_some() + || item.get("server_name").is_some() + { + return "MCP".to_string(); + } + + if item_type.contains("tool") { + return "Plugin".to_string(); + } + + humanize_type(if item_type.is_empty() { + "output" + } else { + item_type.as_str() + }) +} + +fn render_command_event(event_type: &str, item: &Value) -> Option { + if event_type == "item.started" { + return string_field(item, &["command"]).map(|command| format!("$ {command}")); + } + + let output = string_field(item, &["aggregated_output", "output", "result"]) + .unwrap_or_default() + .trim() + .to_string(); + let exit_code = item.get("exit_code").and_then(Value::as_i64); + + match (output.is_empty(), exit_code) { + (false, Some(code)) if code != 0 => Some(format!("{output}\nexit {code}")), + (false, _) => Some(output), + (true, Some(code)) => Some(format!("exit {code}")), + _ => string_field(item, &["command"]).map(|command| format!("$ {command}")), + } +} + +fn render_tool_event(event_type: &str, item: &Value) -> Option { + let name = codex_item_name(item).unwrap_or_else(|| "tool".to_string()); + if event_type == "item.started" { + return Some(name); + } + + let output = string_field( + item, + &["output", "result", "aggregated_output", "summary", "text"], + ) + .unwrap_or_default() + .trim() + .to_string(); + if !output.is_empty() { + return Some(output); + } + + let status = string_field(item, &["status"]).unwrap_or_default(); + if !status.is_empty() && status != "completed" { + return Some(status); + } + + None +} + +fn codex_item_name(item: &Value) -> Option { + string_field( + item, + &["tool_name", "tool", "name", "server_name", "server"], + ) +} + +fn string_field(value: &Value, keys: &[&str]) -> Option { + let Value::Object(map) = value else { + return None; + }; + + keys.iter() + .find_map(|key| map.get(*key).and_then(Value::as_str)) + .map(ToString::to_string) +} + +fn humanize_type(raw: &str) -> String { + raw.split(['-', '_', '.']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => { + let mut word = String::new(); + word.push(first.to_ascii_uppercase()); + word.push_str(chars.as_str()); + word + } + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn first_text(value: &Value) -> Option { + match value { + Value::String(text) => Some(text.clone()), + Value::Array(items) => items.iter().find_map(first_text), + Value::Object(map) => { + for key in ["msg", "message", "text", "content", "summary"] { + if let Some(text) = map.get(key).and_then(Value::as_str) { + return Some(text.to_string()); + } + } + map.values().find_map(first_text) + } + _ => None, + } +} + +fn collect_usage_tokens(value: &Value, input: bool) -> u64 { + match value { + Value::Array(items) => items + .iter() + .map(|item| collect_usage_tokens(item, input)) + .sum(), + Value::Object(map) => { + let mut total = 0; + for (key, child) in map { + let matched_key = if input { + ["input_tokens", "input_token_count", "prompt_tokens"] + } else { + ["output_tokens", "output_token_count", "completion_tokens"] + }; + if matched_key.contains(&key.as_str()) { + total += child.as_u64().unwrap_or_default(); + } else { + total += collect_usage_tokens(child, input); + } + } + total + } + _ => 0, + } +} + +fn should_ignore_codex_stderr(line: &str) -> bool { + line.contains("failed to stat skills entry") + || line.trim() == "Reading additional input from stdin..." +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refresh_usage_snapshot_uses_cached_values_when_available() { + let state = ControllerState { + last_usage_input_tokens: Some(11), + last_usage_output_tokens: Some(29), + ..ControllerState::default() + }; + + let snapshot = refresh_usage_snapshot(&state); + assert!(snapshot.available); + assert_eq!(snapshot.input_tokens, Some(11)); + assert_eq!(snapshot.output_tokens, Some(29)); + assert!(snapshot.refreshed_at.is_some()); + } + + #[test] + fn refresh_usage_snapshot_falls_back_when_usage_missing() { + let snapshot = refresh_usage_snapshot(&ControllerState::default()); + assert!(!snapshot.available); + assert_eq!(snapshot.input_tokens, None); + assert_eq!(snapshot.output_tokens, None); + } + + #[test] + fn filters_known_codex_stderr_noise() { + assert!(should_ignore_codex_stderr( + "2026-04-04T03:43:38Z ERROR codex_core_skills::loader: failed to stat skills entry /tmp/foo" + )); + assert!(should_ignore_codex_stderr( + "Reading additional input from stdin..." + )); + assert!(!should_ignore_codex_stderr("actual planner failure")); + } + + #[test] + fn parses_agent_messages_as_thinking() { + let parsed = parse_codex_line( + r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Planning next step."}}"#, + ); + + assert_eq!(parsed.title, "Thinking"); + assert_eq!(parsed.display, "Planning next step."); + } + + #[test] + fn parses_command_events_into_command_groups() { + let started = parse_codex_line( + r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc pwd","status":"in_progress"}}"#, + ); + let completed = parse_codex_line( + r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc pwd","aggregated_output":"/tmp/demo\n","exit_code":0,"status":"completed"}}"#, + ); + + assert_eq!(started.title, "Command"); + assert_eq!(started.display, "$ /bin/zsh -lc pwd"); + assert_eq!(completed.title, "Command"); + assert_eq!(completed.display, "/tmp/demo"); + } + + #[test] + fn hides_turn_lifecycle_output_but_keeps_usage() { + let parsed = parse_codex_line( + r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":34}}"#, + ); + + assert!(parsed.display.is_empty()); + assert_eq!(parsed.input_tokens, 12); + assert_eq!(parsed.output_tokens, 34); + } +} diff --git a/src/repo.rs b/src/repo.rs new file mode 100644 index 0000000..4f21ec5 --- /dev/null +++ b/src/repo.rs @@ -0,0 +1,62 @@ +use std::env; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static RUN_ID: AtomicU64 = AtomicU64::new(1); + +pub fn repo_root() -> PathBuf { + env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +pub fn now_timestamp() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + seconds.to_string() +} + +pub fn now_timestamp_u64() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +pub fn parse_timestamp(timestamp: &str) -> Option { + timestamp.parse::().ok() +} + +pub fn format_age(timestamp: Option<&str>) -> String { + let Some(timestamp) = timestamp.and_then(parse_timestamp) else { + return "--".to_string(); + }; + let now = now_timestamp_u64(); + if now <= timestamp { + return "now".to_string(); + } + + let seconds = now - timestamp; + if seconds < 60 { + format!("{seconds}s ago") + } else if seconds < 3_600 { + format!("{}m ago", seconds / 60) + } else if seconds < 86_400 { + format!("{}h ago", seconds / 3_600) + } else { + format!("{}d ago", seconds / 86_400) + } +} + +pub fn absolute(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + repo_root().join(path) + } +} + +pub fn next_run_id() -> u64 { + RUN_ID.fetch_add(1, Ordering::Relaxed) +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..31fa14c --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1 @@ +pub mod toon; diff --git a/src/storage/toon.rs b/src/storage/toon.rs new file mode 100644 index 0000000..52fcc57 --- /dev/null +++ b/src/storage/toon.rs @@ -0,0 +1,388 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{de::DeserializeOwned, Serialize}; +use tempfile::NamedTempFile; +use toon_format::{decode_default, encode_default}; + +use crate::model::{ControllerState, ControllerSummary, Plan, TaskConfig}; +use crate::repo; + +const DEFAULT_GOAL: &str = "# Goal\n\nDescribe the goal for this controller.\n"; +const DEFAULT_STANDARDS: &str = + "# Standards\n\n- Keep code maintainable.\n- Avoid one-off hacks.\n- Leave tests green.\n"; +const CONTROLLERS_ROOT: &str = ".agent/controllers"; +const MAX_CONTROLLER_ID_LEN: usize = 48; + +pub fn ensure_controller_files(config: &TaskConfig) -> Result<()> { + for path in [ + &config.goal_file, + &config.plan_file, + &config.state_file, + &config.standards_file, + ] { + if let Some(parent) = repo::absolute(path).parent() { + fs::create_dir_all(parent)?; + } + } + + if !repo::absolute(&config.goal_file).exists() { + fs::write(repo::absolute(&config.goal_file), DEFAULT_GOAL)?; + } + if !repo::absolute(&config.standards_file).exists() { + fs::write(repo::absolute(&config.standards_file), DEFAULT_STANDARDS)?; + } + if !repo::absolute(&config.plan_file).exists() { + write_plan(&config.plan_file, &Plan::default())?; + } + if !repo::absolute(&config.state_file).exists() { + write_state(&config.state_file, &ControllerState::default())?; + } + + Ok(()) +} + +pub fn read_task_config(path: &Path) -> Result { + read_from_toon(path) +} + +pub fn write_task_config(path: &Path, config: &TaskConfig) -> Result<()> { + write_to_toon(path, config) +} + +pub fn read_plan(path: &Path) -> Result { + read_from_toon(path) +} + +pub fn write_plan(path: &Path, plan: &Plan) -> Result<()> { + write_to_toon(path, plan) +} + +pub fn read_state(path: &Path) -> Result { + read_from_toon(path) +} + +pub fn write_state(path: &Path, state: &ControllerState) -> Result<()> { + write_to_toon(path, state) +} + +pub fn read_markdown(path: &Path) -> Result { + fs::read_to_string(repo::absolute(path)) + .with_context(|| format!("failed to read {}", path.display())) +} + +pub fn write_markdown(path: &Path, content: &str) -> Result<()> { + let absolute = repo::absolute(path); + if let Some(parent) = absolute.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&absolute, content).with_context(|| format!("failed to write {}", path.display())) +} + +pub fn list_controller_summaries() -> Result> { + list_controller_summaries_in(&repo::absolute(Path::new(CONTROLLERS_ROOT))) +} + +pub fn controller_exists(controller_id: &str) -> bool { + repo::absolute(&Path::new(CONTROLLERS_ROOT).join(controller_id)).is_dir() +} + +pub fn normalize_controller_id_candidate(input: &str) -> String { + let mut slug = String::new(); + let mut pending_dash = false; + + for ch in input.chars() { + if ch.is_ascii_alphanumeric() { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + slug.push(ch.to_ascii_lowercase()); + pending_dash = false; + } else if !slug.is_empty() { + pending_dash = true; + } + + if slug.len() >= MAX_CONTROLLER_ID_LEN { + break; + } + } + + slug.trim_matches('-').to_string() +} + +pub fn make_unique_controller_id(candidate: &str) -> String { + let mut controller_id = normalize_controller_id_candidate(candidate); + if controller_id.is_empty() { + controller_id = fallback_controller_id(); + } + + if !controller_exists(&controller_id) { + return controller_id; + } + + for suffix in 2..10_000 { + let candidate = controller_id_with_suffix(&controller_id, suffix); + if !controller_exists(&candidate) { + return candidate; + } + } + + controller_id_with_suffix(&fallback_controller_id(), repo::next_run_id()) +} + +fn fallback_controller_id() -> String { + format!("controller-{}", repo::now_timestamp_u64()) +} + +fn controller_id_with_suffix(base: &str, suffix: impl std::fmt::Display) -> String { + let suffix = format!("-{suffix}"); + let base_len = MAX_CONTROLLER_ID_LEN.saturating_sub(suffix.len()); + let mut trimmed = base.chars().take(base_len).collect::(); + trimmed = trimmed.trim_matches('-').to_string(); + + if trimmed.is_empty() { + fallback_controller_id() + } else { + format!("{trimmed}{suffix}") + } +} + +pub fn create_controller(task_path: &Path, controller_id: &str) -> Result { + let config = TaskConfig::default_for(controller_id); + write_task_config(task_path, &config)?; + ensure_controller_files(&config)?; + Ok(config) +} + +fn list_controller_summaries_in(root: &Path) -> Result> { + if !root.exists() { + return Ok(Vec::new()); + } + + let mut controllers = Vec::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let Some(controller_id) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + let config = TaskConfig::default_for(controller_id); + let goal_md = read_markdown_from_root(root, controller_id, "goal.md") + .unwrap_or_else(|_| DEFAULT_GOAL.to_string()); + let plan = + read_toon_from_root::(root, controller_id, "plan.toon").unwrap_or_default(); + let state = read_toon_from_root::(root, controller_id, "state.toon") + .unwrap_or_default(); + controllers.push(ControllerSummary { + id: controller_id.to_string(), + goal_summary: goal_summary(&goal_md, &plan), + phase: state.phase.clone(), + current_step_id: state.current_step_id.clone(), + completed_steps: state.completed_steps.len(), + total_steps: plan.steps.len(), + last_updated: controller_last_updated(&state), + branch: config.branch.clone(), + }); + } + + controllers.sort_by(|left, right| { + right + .last_updated + .cmp(&left.last_updated) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(controllers) +} + +fn read_markdown_from_root(root: &Path, controller_id: &str, filename: &str) -> Result { + let path = root.join(controller_id).join(filename); + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display())) +} + +fn read_toon_from_root( + root: &Path, + controller_id: &str, + filename: &str, +) -> Result { + let path = root.join(controller_id).join(filename); + let content = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + decode_default(&content).with_context(|| format!("failed to decode {}", path.display())) +} + +fn goal_summary(goal_md: &str, plan: &Plan) -> String { + goal_md + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + .map(ToString::to_string) + .filter(|line| !line.is_empty()) + .unwrap_or_else(|| plan.goal_summary.clone()) +} + +fn controller_last_updated(state: &ControllerState) -> Option { + state + .last_usage_refresh_at + .clone() + .or_else(|| state.history.last().map(|event| event.timestamp.clone())) + .or_else(|| state.started_at.clone()) +} + +fn read_from_toon(path: &Path) -> Result { + let content = fs::read_to_string(repo::absolute(path)) + .with_context(|| format!("failed to read {}", path.display()))?; + decode_default(&content).with_context(|| format!("failed to decode {}", path.display())) +} + +fn write_to_toon(path: &Path, value: &T) -> Result<()> { + let content = + encode_default(value).with_context(|| format!("failed to encode {}", path.display()))?; + let absolute = repo::absolute(path); + if let Some(parent) = absolute.parent() { + fs::create_dir_all(parent)?; + } + let mut temp = NamedTempFile::new_in( + absolute + .parent() + .context("expected file to have a parent directory")?, + )?; + use std::io::Write; + temp.write_all(content.as_bytes())?; + temp.persist(absolute)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use crate::model::{ControllerPhase, ControllerState, Plan, TaskConfig}; + + use super::*; + + #[test] + fn task_config_roundtrip_uses_toon() { + let temp = tempdir().expect("tempdir"); + let path_buf = temp.path().join(".agent/controller-loop/task.toon"); + let path = path_buf.as_path(); + let config = TaskConfig::default_for("roundtrip"); + write_task_config(path, &config).expect("write task config"); + + let decoded = read_task_config(path).expect("read task config"); + assert_eq!(decoded.engine, "data-driven-v1"); + assert_eq!(decoded.branch, "codex/roundtrip"); + } + + #[test] + fn default_plan_and_state_roundtrip() { + let temp = tempdir().expect("tempdir"); + let plan_path_buf = temp.path().join(".agent/controllers/example/plan.toon"); + let state_path_buf = temp.path().join(".agent/controllers/example/state.toon"); + let plan_path = plan_path_buf.as_path(); + let state_path = state_path_buf.as_path(); + + write_plan(plan_path, &Plan::default()).expect("write plan"); + write_state(state_path, &ControllerState::default()).expect("write state"); + + let plan = read_plan(plan_path).expect("read plan"); + let state = read_state(state_path).expect("read state"); + + assert_eq!(plan.goal_summary, "No plan yet"); + assert!(matches!( + state.phase, + crate::model::ControllerPhase::Planning + )); + } + + #[test] + fn normalize_controller_id_candidate_slugifies_model_output() { + assert_eq!( + normalize_controller_id_candidate( + "Build the controller picker for security hardening!" + ), + "build-the-controller-picker-for-security-hardeni" + ); + assert_eq!(normalize_controller_id_candidate("???"), ""); + } + + #[test] + fn make_unique_controller_id_falls_back_and_suffixes_cleanly() { + assert_eq!( + controller_id_with_suffix("deep-cleanup", 2), + "deep-cleanup-2" + ); + let fallback = make_unique_controller_id("???"); + assert!(fallback.starts_with("controller-")); + } + + #[test] + fn controller_discovery_handles_partial_files() { + let temp = tempdir().expect("tempdir"); + let root = temp.path().join(".agent/controllers"); + fs::create_dir_all(root.join("alpha")).expect("create alpha"); + fs::create_dir_all(root.join("beta")).expect("create beta"); + fs::write( + root.join("alpha/goal.md"), + "# Goal\n\nShip the picker experience.\n", + ) + .expect("write goal"); + fs::write( + root.join("alpha/plan.toon"), + encode_default(&Plan { + version: 1, + goal_summary: "Alpha goal".to_string(), + steps: vec![], + }) + .expect("encode plan"), + ) + .expect("write plan"); + fs::write( + root.join("alpha/state.toon"), + encode_default(&ControllerState { + phase: ControllerPhase::Executing, + started_at: Some("10".to_string()), + ..ControllerState::default() + }) + .expect("encode state"), + ) + .expect("write state"); + fs::write( + root.join("beta/goal.md"), + "# Goal\n\nMissing state and plan.\n", + ) + .expect("write partial goal"); + + let controllers = list_controller_summaries_in(&root).expect("list controllers"); + assert_eq!(controllers.len(), 2); + assert_eq!(controllers[0].id, "alpha"); + assert_eq!(controllers[0].goal_summary, "Ship the picker experience."); + assert_eq!(controllers[0].phase, ControllerPhase::Executing); + assert_eq!(controllers[1].id, "beta"); + assert_eq!(controllers[1].total_steps, 0); + } + + #[test] + fn create_controller_writes_task_config_and_files() { + let temp = tempdir().expect("tempdir"); + let task_path = temp.path().join(".agent/controller-loop/task.toon"); + let previous_cwd = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(temp.path()).expect("set cwd"); + + let config = create_controller(&task_path, "picker").expect("create controller"); + assert_eq!(config.controller_id(), "picker"); + assert!(temp + .path() + .join(".agent/controllers/picker/goal.md") + .exists()); + assert!(task_path.exists()); + + std::env::set_current_dir(previous_cwd).expect("restore cwd"); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..a1b1b17 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,905 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Margin, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{ + Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, + ScrollbarState, Wrap, + }, + Frame, +}; + +use crate::app::App; +use crate::model::{ + ControllerPhase, Screen, SessionGroup, SessionSelection, SessionSource, SessionStream, +}; +use crate::repo; + +const BORDER: Color = Color::DarkGray; +const BORDER_ACTIVE: Color = Color::Blue; +const TEXT: Color = Color::Reset; +const TEXT_DIM: Color = Color::DarkGray; +const CYAN: Color = Color::Cyan; +const GREEN: Color = Color::Green; +const GOLD: Color = Color::Yellow; +const RED: Color = Color::Red; +const MAGENTA: Color = Color::Magenta; +const BLUE: Color = Color::Blue; + +#[derive(Debug, Clone)] +pub(crate) struct WorkspaceLayout { + pub session: Rect, + pub sidebar: Rect, + pub status: Rect, + pub composer: Rect, +} + +#[derive(Debug, Clone)] +pub(crate) struct SessionRenderRow { + pub text: String, + pub border_style: Style, + pub content_style: Style, + pub selectable_range: Option<(usize, usize)>, +} + +impl WorkspaceLayout { + pub fn session_text_rect(&self, with_scrollbar: bool) -> Rect { + let extra_right = if with_scrollbar { 1 } else { 0 }; + Rect { + x: self.session.x.saturating_add(2), + y: self.session.y.saturating_add(1), + width: self + .session + .width + .saturating_sub(4) + .saturating_sub(extra_right), + height: self.session.height.saturating_sub(2), + } + } +} + +pub(crate) fn workspace_layout(area: Rect) -> WorkspaceLayout { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(3), + Constraint::Length(3), + ]) + .split(area); + + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(74), Constraint::Percentage(26)]) + .split(outer[0]); + + WorkspaceLayout { + session: top[0], + sidebar: top[1], + status: outer[1], + composer: outer[2], + } +} + +pub fn render(frame: &mut Frame, app: &App) { + match app.screen { + Screen::ControllerPicker => render_picker(frame, app), + Screen::CreateController => render_create_controller(frame, app), + Screen::Workspace => render_workspace(frame, app), + } +} + +fn render_picker(frame: &mut Frame, app: &App) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(frame.area()); + + let mut lines = vec![ + Line::from(Span::styled( + "Select a controller loop or create a new one.", + Style::default().fg(CYAN).add_modifier(Modifier::BOLD), + )), + Line::from(""), + ]; + + for (index, controller) in app.picker_items.iter().enumerate() { + let selected = app.picker_selected == index; + let prefix = if selected { "> " } else { " " }; + let style = if selected { + Style::default() + .fg(TEXT) + .add_modifier(Modifier::REVERSED) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(TEXT) + }; + lines.push(Line::from(vec![ + Span::styled( + if selected { "▌ " } else { " " }, + Style::default().fg(if selected { BORDER_ACTIVE } else { BORDER }), + ), + Span::styled( + format!( + "{prefix}{} [{}] {}/{} done current={}", + controller.id, + controller.phase.label(), + controller.completed_steps, + controller.total_steps, + controller + .current_step_id + .clone() + .unwrap_or_else(|| "none".to_string()) + ), + style, + ), + ])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + "goal ", + Style::default().fg(TEXT_DIM).add_modifier(Modifier::ITALIC), + ), + Span::styled( + controller.goal_summary.clone(), + Style::default().fg(if selected { TEXT } else { TEXT_DIM }), + ), + Span::raw(" "), + Span::styled("updated ", Style::default().fg(TEXT_DIM)), + Span::styled( + repo::format_age(controller.last_updated.as_deref()), + Style::default().fg(TEXT_DIM), + ), + ])); + lines.push(Line::from("")); + } + + let create_selected = app.picker_selected == app.picker_items.len(); + let create_style = if create_selected { + Style::default() + .fg(TEXT) + .add_modifier(Modifier::REVERSED) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(GREEN) + }; + lines.push(Line::from(vec![ + Span::styled( + if create_selected { "▌ " } else { " " }, + Style::default().fg(if create_selected { GREEN } else { BORDER }), + ), + Span::styled( + format!( + "{}Create new controller", + if create_selected { "> " } else { " " } + ), + create_style, + ), + ])); + + let picker = Paragraph::new(lines) + .block(shell_block(" Controller Picker ", true)) + .style(Style::default().fg(TEXT)) + .wrap(Wrap { trim: false }); + let footer = Paragraph::new("Up/Down or j/k to move. Enter opens. n creates. Esc quits.") + .block(shell_block(" Controls ", false)) + .style(Style::default().fg(TEXT_DIM)); + + frame.render_widget(picker, outer[0]); + frame.render_widget(footer, outer[1]); +} + +fn render_create_controller(frame: &mut Frame, app: &App) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(6)]) + .split(frame.area()); + + let mut lines = vec![ + Line::from(Span::styled( + "Describe the work this controller should own. The first submission goes straight into the Codex planning helper.", + Style::default().fg(CYAN).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled("Controller id ", Style::default().fg(TEXT_DIM)), + Span::styled( + "generated by GPT-5.4 mini on submit", + Style::default().fg(GREEN), + ), + ]), + Line::from(""), + Line::from(Span::styled( + "Example: Build the intuitive controller-first TUI picker and workspace.", + Style::default().fg(TEXT_DIM), + )), + ]; + + if let Some(error) = &app.create_error { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + error.clone(), + Style::default().fg(RED).add_modifier(Modifier::BOLD), + ))); + } + + let help = Paragraph::new(lines) + .block(shell_block(" Create Controller ", true)) + .style(Style::default().fg(TEXT)) + .wrap(Wrap { trim: false }); + let composer_scroll = composer_scroll_offset(app.create_input.as_str(), outer[1], true); + let composer = Paragraph::new(app.create_input.as_str()) + .block(shell_block(" Goal-First Planning Input ", true)) + .style(Style::default().fg(TEXT)) + .wrap(Wrap { trim: false }) + .scroll((composer_scroll as u16, 0)); + + frame.render_widget(help, outer[0]); + frame.render_widget(composer, outer[1]); +} + +fn render_workspace(frame: &mut Frame, app: &App) { + let layout = workspace_layout(frame.area()); + let session_rows = build_session_render_rows(&app.workspace_groups()); + let session_has_scrollbar = + session_rows.len() > layout.session_text_rect(false).height as usize; + let session_lines = render_session_lines(&session_rows, app.workspace_session_selection()); + let session = Paragraph::new(session_lines.clone()) + .block(shell_block(" Session ", true)) + .style(Style::default().fg(TEXT)) + .scroll((app.workspace_session_scroll() as u16, 0)) + .wrap(Wrap { trim: false }); + + let sidebar = Paragraph::new(plan_board_lines(app)) + .block(shell_block(" Plan Board ", false)) + .style(Style::default().fg(TEXT)) + .wrap(Wrap { trim: false }); + + let status = Paragraph::new(status_line(app)) + .block(shell_block(" Status ", true)) + .style(Style::default().fg(TEXT_DIM)); + + let composer_title = match app + .workspace() + .map(|workspace| workspace.state.phase.clone()) + .unwrap_or(ControllerPhase::Planning) + { + ControllerPhase::Planning => "Composer (planning input)", + _ => "Composer (execution commands or notes)", + }; + let composer_scroll = composer_scroll_offset( + app.workspace_input().unwrap_or_default(), + layout.composer, + true, + ); + let composer = Paragraph::new(app.workspace_input().unwrap_or_default()) + .block(shell_block(&format!(" {composer_title} "), true)) + .style(Style::default().fg(TEXT)) + .wrap(Wrap { trim: false }) + .scroll((composer_scroll as u16, 0)); + + frame.render_widget(session, layout.session); + frame.render_widget(sidebar, layout.sidebar); + frame.render_widget(status, layout.status); + frame.render_widget(composer, layout.composer); + + if session_has_scrollbar { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .track_symbol(Some("│")) + .thumb_symbol("█") + .track_style(Style::default().fg(BORDER)) + .thumb_style(Style::default().fg(BORDER_ACTIVE)); + let mut scrollbar_state = + ScrollbarState::new(session_lines.len()).position(app.workspace_session_scroll()); + frame.render_stateful_widget( + scrollbar, + layout.session.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } +} + +pub(crate) fn build_session_render_rows(groups: &[SessionGroup]) -> Vec { + let mut rows = Vec::new(); + for group in groups { + let accent_style = session_group_style(group); + let body_style = if matches!(group.stream, SessionStream::Stderr) { + Style::default().fg(RED) + } else { + Style::default().fg(TEXT) + }; + + let tag = group.tag.clone().unwrap_or_default(); + let header = format!( + "┌ {} [{}]{}{}", + group.title, + group.source.label(), + if matches!(group.stream, SessionStream::Stdout) { + String::new() + } else { + format!(" {}", group.stream.label()) + }, + if tag.is_empty() { + String::new() + } else { + format!(" {tag}") + } + ); + rows.push(SessionRenderRow { + selectable_range: Some((2, header.chars().count())), + text: header, + border_style: accent_style, + content_style: accent_style.add_modifier(Modifier::BOLD), + }); + + for line in &group.lines { + let text = format!("│ {line}"); + rows.push(SessionRenderRow { + selectable_range: Some((2, text.chars().count())), + text, + border_style: accent_style, + content_style: body_style, + }); + } + rows.push(SessionRenderRow { + text: "╰─".to_string(), + border_style: accent_style, + content_style: accent_style, + selectable_range: None, + }); + rows.push(SessionRenderRow { + text: String::new(), + border_style: Style::default(), + content_style: Style::default(), + selectable_range: Some((0, 0)), + }); + } + + rows +} + +fn render_session_lines( + rows: &[SessionRenderRow], + selection: Option<&SessionSelection>, +) -> Vec> { + rows.iter() + .enumerate() + .map(|(index, row)| render_session_row(row, index, selection)) + .collect() +} + +fn render_session_row( + row: &SessionRenderRow, + row_index: usize, + selection: Option<&SessionSelection>, +) -> Line<'static> { + let total_chars = row.text.chars().count(); + let content_start = row + .selectable_range + .map(|(start, _)| start) + .unwrap_or(total_chars); + let mut spans = Vec::new(); + if content_start > 0 { + spans.push(Span::styled( + slice_chars(&row.text, 0, content_start), + row.border_style, + )); + } + + let Some((selected_start, selected_end)) = selected_range_for_row(row, row_index, selection) + else { + if content_start < total_chars { + spans.push(Span::styled( + slice_chars(&row.text, content_start, total_chars), + row.content_style, + )); + } + return Line::from(spans); + }; + + if selected_start > content_start { + spans.push(Span::styled( + slice_chars(&row.text, content_start, selected_start), + row.content_style, + )); + } + spans.push(Span::styled( + slice_chars(&row.text, selected_start, selected_end), + selected_style(row.content_style), + )); + if selected_end < total_chars { + spans.push(Span::styled( + slice_chars(&row.text, selected_end, total_chars), + row.content_style, + )); + } + Line::from(spans) +} + +fn selected_range_for_row( + row: &SessionRenderRow, + row_index: usize, + selection: Option<&SessionSelection>, +) -> Option<(usize, usize)> { + let selection = selection?; + let selectable = row.selectable_range?; + let (start, end) = selection.ordered(); + if row_index < start.line || row_index > end.line { + return None; + } + + let raw_start = if row_index == start.line { + start.column + } else { + 0 + }; + let raw_end = if row_index == end.line { + end.column.saturating_add(1) + } else { + row.text.chars().count() + }; + + let clipped_start = raw_start.max(selectable.0); + let clipped_end = raw_end.min(selectable.1); + (clipped_end > clipped_start).then_some((clipped_start, clipped_end)) +} + +pub(crate) fn selected_session_text( + rows: &[SessionRenderRow], + selection: &SessionSelection, +) -> Option { + let (start, end) = selection.ordered(); + let end_line = end.line.min(rows.len().saturating_sub(1)); + let start_line = start.line.min(end_line); + let mut output = Vec::new(); + + for row_index in start_line..=end_line { + let row = rows.get(row_index)?; + let text = if let Some((selected_start, selected_end)) = + selected_range_for_row(row, row_index, Some(selection)) + { + slice_chars(&row.text, selected_start, selected_end) + } else { + String::new() + }; + output.push(text); + } + + Some(output.join("\n")) +} + +fn slice_chars(text: &str, start: usize, end: usize) -> String { + text.chars() + .skip(start) + .take(end.saturating_sub(start)) + .collect() +} + +fn plan_board_lines(app: &App) -> Vec> { + let Some(workspace) = app.workspace() else { + return vec![Line::from("No workspace loaded.")]; + }; + + let current_objective = workspace + .plan + .current_step_title(workspace.state.current_step_id.as_deref()) + .or_else(|| { + workspace + .plan + .steps + .iter() + .find(|step| matches!(step.status, crate::model::StepStatus::Todo)) + .map(|step| step.title.clone()) + }) + .unwrap_or_else(|| "none".to_string()); + + let mut lines = Vec::new(); + push_sidebar_field( + &mut lines, + "Phase", + workspace.state.phase.label(), + Style::default().fg(CYAN), + ); + push_sidebar_field( + &mut lines, + "Goal", + workspace.plan.goal_summary.clone(), + Style::default().fg(TEXT), + ); + push_sidebar_field( + &mut lines, + "Focus", + current_objective, + Style::default().fg(TEXT), + ); + push_sidebar_field( + &mut lines, + "Active Step", + workspace + .state + .current_step_id + .clone() + .unwrap_or_else(|| "none".to_string()), + Style::default().fg(TEXT), + ); + + lines.push(Line::from(Span::styled( + "Plan", + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + for step in &workspace.plan.steps { + lines.push(Line::from(vec![ + Span::styled( + step.status.marker(), + Style::default().fg(step_color(step.status.clone())), + ), + Span::raw(" "), + Span::styled(format!("{}:", step.id), Style::default().fg(TEXT_DIM)), + ])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + step.title.clone(), + Style::default().fg(TEXT).add_modifier( + if matches!(step.status, crate::model::StepStatus::Active) { + Modifier::BOLD + } else { + Modifier::empty() + }, + ), + ), + ])); + lines.push(Line::from("")); + } + + if !workspace.state.blocked_steps.is_empty() { + lines.push(Line::from(Span::styled( + "Blocked", + Style::default().fg(RED).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + for blocked in &workspace.state.blocked_steps { + lines.push(Line::from(vec![ + Span::styled("• ", Style::default().fg(RED)), + Span::styled(blocked.clone(), Style::default().fg(TEXT)), + ])); + } + lines.push(Line::from("")); + } + + if let Some(summary) = &workspace.state.last_verification { + lines.push(Line::from(Span::styled( + "Verification", + Style::default().fg(GREEN).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + summary.summary.clone(), + Style::default().fg(TEXT), + ))); + lines.push(Line::from("")); + } + + if let Some(summary) = &workspace.state.last_full_test_summary { + lines.push(Line::from(Span::styled( + "Tests", + Style::default().fg(GREEN).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + summary.summary.clone(), + Style::default().fg(TEXT), + ))); + lines.push(Line::from("")); + } + + lines +} + +fn status_line(app: &App) -> Line<'static> { + let Some(status) = app.workspace_status_snapshot() else { + return Line::from("No workspace loaded."); + }; + + let usage_age = repo::format_age(status.usage.refreshed_at.as_deref()); + let usage_label = if status.usage.available { + usage_age + } else { + format!( + "{} ({})", + usage_age, + status + .usage + .note + .unwrap_or_else(|| "unavailable".to_string()) + ) + }; + + Line::from(vec![ + status_kv("controller", status.controller_id, CYAN), + Span::raw(" "), + status_kv("branch", status.branch, TEXT), + Span::raw(" "), + status_kv( + "started", + repo::format_age(status.started_at.as_deref()), + TEXT, + ), + Span::raw(" "), + status_kv("phase", status.phase.label(), CYAN), + Span::raw(" "), + status_kv("iter", status.iteration.to_string(), TEXT), + Span::raw(" "), + status_kv("tok_in", token_label(status.session_input_tokens), GOLD), + Span::raw(" "), + status_kv("tok_out", token_label(status.session_output_tokens), GOLD), + Span::raw(" "), + status_kv("codex", usage_label, TEXT_DIM), + ]) +} + +fn token_label(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "--".to_string()) +} + +fn source_style(source: SessionSource) -> Style { + Style::default().fg(source_color(source)) +} + +fn source_color(source: SessionSource) -> Color { + match source { + SessionSource::User => MAGENTA, + SessionSource::Controller => BLUE, + SessionSource::Planner => CYAN, + SessionSource::Executor => GREEN, + SessionSource::Verifier => GOLD, + SessionSource::Warning => RED, + } +} + +fn shell_block(title: &str, active: bool) -> Block<'static> { + Block::default() + .title(Span::styled( + title.to_string(), + Style::default() + .fg(if active { TEXT } else { TEXT_DIM }) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(Style::default().fg(if active { BORDER_ACTIVE } else { BORDER })) + .style(Style::default()) + .padding(Padding::horizontal(1)) +} + +fn status_kv(label: &str, value: impl Into, value_color: Color) -> Span<'static> { + Span::styled( + format!("{label}={}", value.into()), + Style::default().fg(value_color), + ) +} + +fn step_color(status: crate::model::StepStatus) -> Color { + match status { + crate::model::StepStatus::Todo => TEXT_DIM, + crate::model::StepStatus::Active => CYAN, + crate::model::StepStatus::Done => GREEN, + crate::model::StepStatus::Blocked => RED, + } +} + +fn session_group_style(group: &SessionGroup) -> Style { + let color = if matches!(group.stream, SessionStream::Stderr) { + RED + } else { + match group.title.as_str() { + "Thinking" => CYAN, + "Command" => BLUE, + "Patch" => GREEN, + "MCP" => MAGENTA, + "Plugin" => GOLD, + _ => source_color(group.source), + } + }; + Style::default().fg(color) +} + +fn selected_style(style: Style) -> Style { + style.add_modifier(Modifier::REVERSED) +} + +fn push_sidebar_field( + lines: &mut Vec>, + label: &str, + value: impl Into, + value_style: Style, +) { + lines.push(Line::from(Span::styled( + label.to_string(), + Style::default().fg(TEXT_DIM).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(value.into(), value_style), + ])); + lines.push(Line::from("")); +} + +fn composer_scroll_offset(text: &str, area: ratatui::layout::Rect, padded_block: bool) -> usize { + let horizontal_chrome = if padded_block { 4 } else { 2 }; + let visible_rows = area.height.saturating_sub(2) as usize; + let width = area.width.saturating_sub(horizontal_chrome) as usize; + wrapped_line_count(text, width).saturating_sub(visible_rows) +} + +fn wrapped_line_count(text: &str, width: usize) -> usize { + if width == 0 { + return 0; + } + + let mut total = 0; + for line in text.split('\n') { + let chars = line.chars().count(); + total += chars.max(1).div_ceil(width); + } + total.max(1) +} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + use std::time::Instant; + + use ratatui::{backend::TestBackend, Terminal}; + + use crate::app::WorkspaceRuntime; + use crate::model::{ + ControllerPhase, ControllerState, Plan, Screen, SessionEntry, SessionSource, SessionStream, + TaskConfig, UsageSnapshot, + }; + + use super::*; + + fn sample_app(screen: Screen) -> App { + let (_control_tx, control_rx) = mpsc::channel(); + let (event_tx, event_rx) = mpsc::channel(); + drop(control_rx); + drop(event_tx); + App { + screen, + picker_items: vec![crate::model::ControllerSummary { + id: "alpha".to_string(), + goal_summary: "Ship the picker".to_string(), + phase: ControllerPhase::Planning, + current_step_id: Some("s1".to_string()), + completed_steps: 1, + total_steps: 3, + last_updated: Some("10".to_string()), + branch: "codex/alpha".to_string(), + }], + picker_selected: 0, + create_input: "Build the picker flow".to_string(), + create_error: None, + default_task_path: std::path::PathBuf::from(".agent/controller-loop/task.toon"), + workspace: Some(WorkspaceRuntime { + task_config: TaskConfig::default_for("alpha"), + goal_md: "# Goal\n\nShip it.\n".to_string(), + standards_md: "# Standards\n\n- Keep tests green.\n".to_string(), + plan: Plan { + version: 1, + goal_summary: "Ship the picker".to_string(), + steps: vec![], + }, + state: ControllerState { + phase: ControllerPhase::Planning, + started_at: Some("10".to_string()), + ..ControllerState::default() + }, + input: "hello".to_string(), + session_entries: vec![SessionEntry { + source: SessionSource::Planner, + stream: SessionStream::Stdout, + title: "Thought".to_string(), + tag: Some("alpha".to_string()), + body: "Plan the controller picker.".to_string(), + run_id: 1, + }], + event_rx, + control_tx: _control_tx, + session_input_tokens: Some(12), + session_output_tokens: Some(34), + usage_snapshot: UsageSnapshot::unavailable("usage unavailable"), + last_usage_refresh: Instant::now(), + session_scroll: 0, + session_follow_output: true, + session_viewport_lines: 0, + session_selection: None, + session_drag_active: false, + }), + } + } + + fn render_to_text(app: &App) -> String { + let backend = TestBackend::new(100, 40); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal.draw(|frame| render(frame, app)).expect("draw"); + let backend = terminal.backend(); + backend + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::>() + .join("") + } + + #[test] + fn renders_picker_screen() { + let app = sample_app(Screen::ControllerPicker); + let rendered = render_to_text(&app); + assert!(rendered.contains("Controller Picker")); + assert!(rendered.contains("Create new controller")); + } + + #[test] + fn renders_create_screen() { + let app = sample_app(Screen::CreateController); + let rendered = render_to_text(&app); + assert!(rendered.contains("Create Controller")); + assert!(rendered.contains("generated by GPT-5.4 mini")); + } + + #[test] + fn renders_workspace_screen() { + let mut app = sample_app(Screen::Workspace); + if let Some(workspace) = app.workspace.as_mut() { + workspace.plan.steps.push(crate::model::PlanStep { + id: "s1".to_string(), + title: "Design picker".to_string(), + status: crate::model::StepStatus::Todo, + ..crate::model::PlanStep::default() + }); + } + let rendered = render_to_text(&app); + assert!(rendered.contains("Session")); + assert!(rendered.contains("Plan Board")); + assert!(rendered.contains("controller=alpha")); + } + + #[test] + fn wrapped_line_count_handles_long_input() { + assert_eq!(wrapped_line_count("", 10), 1); + assert_eq!(wrapped_line_count("abcdefghij", 10), 1); + assert_eq!(wrapped_line_count("abcdefghijk", 10), 2); + assert_eq!(wrapped_line_count("abc\ndefghijk", 4), 3); + } + + #[test] + fn selected_session_text_skips_decorative_prefixes() { + let rows = build_session_render_rows(&[SessionGroup { + source: SessionSource::Planner, + stream: SessionStream::Stdout, + title: "Error".to_string(), + tag: Some("alpha".to_string()), + lines: vec!["plain text".to_string()], + run_id: 1, + }]); + let selection = crate::model::SessionSelection { + anchor: crate::model::SessionCursor { line: 0, column: 2 }, + focus: crate::model::SessionCursor { line: 1, column: 6 }, + }; + + let text = selected_session_text(&rows, &selection).expect("selection text"); + assert!(text.starts_with("Error [Planner]")); + assert!(text.contains("plain")); + assert!(!text.contains("┌ ")); + assert!(!text.contains("│ ")); + } +}