feat: ui
This commit is contained in:
9
.agent/controller-loop/task.toon
Normal file
9
.agent/controller-loop/task.toon
Normal file
@@ -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
|
||||||
18
.agent/controllers/AGENTS.md
Normal file
18
.agent/controllers/AGENTS.md
Normal file
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Goal
|
||||||
|
|
||||||
|
Describe the goal for this controller.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version: 1
|
||||||
|
goal_summary: No plan yet
|
||||||
|
steps[0]:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Standards
|
||||||
|
|
||||||
|
- Keep code maintainable.
|
||||||
|
- Avoid one-off hacks.
|
||||||
|
- Leave tests green.
|
||||||
@@ -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
|
||||||
4
.agent/controllers/controller-loop/goal.md
Normal file
4
.agent/controllers/controller-loop/goal.md
Normal file
@@ -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.
|
||||||
|
|
||||||
3
.agent/controllers/controller-loop/plan.toon
Normal file
3
.agent/controllers/controller-loop/plan.toon
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version: 1
|
||||||
|
goal_summary: Rust TUI-first autonomous controller
|
||||||
|
steps[0]:
|
||||||
8
.agent/controllers/controller-loop/standards.md
Normal file
8
.agent/controllers/controller-loop/standards.md
Normal file
@@ -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.
|
||||||
|
|
||||||
17
.agent/controllers/controller-loop/state.toon
Normal file
17
.agent/controllers/controller-loop/state.toon
Normal file
@@ -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]:
|
||||||
20
.agent/controllers/keystone-seam-audit/goal.md
Normal file
20
.agent/controllers/keystone-seam-audit/goal.md
Normal file
@@ -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.
|
||||||
94
.agent/controllers/keystone-seam-audit/plan.toon
Normal file
94
.agent/controllers/keystone-seam-audit/plan.toon
Normal file
@@ -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' <top-candidate-file>","sed -n '221,440p' <top-candidate-file>"
|
||||||
|
- label: Map exports and dependents
|
||||||
|
commands[1]: "rg -n \"from ['\\\"]|require\\(\" <candidate-basename-or-symbols>"
|
||||||
|
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]: "<smallest-relevant-test-or-lint-command>"
|
||||||
|
- label: Confirm file size reduction
|
||||||
|
commands[1]: "wc -l <affected-files>"
|
||||||
|
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]: "<smallest-relevant-test-or-lint-command>"
|
||||||
|
- 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]: "<repo-test-command>","<repo-lint-command-if-available>","<repo-build-or-typecheck-command-if-available>"
|
||||||
|
- 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
|
||||||
14
.agent/controllers/keystone-seam-audit/standards.md
Normal file
14
.agent/controllers/keystone-seam-audit/standards.md
Normal file
@@ -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.
|
||||||
25
.agent/controllers/keystone-seam-audit/state.toon
Normal file
25
.agent/controllers/keystone-seam-audit/state.toon
Normal file
@@ -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
|
||||||
19
.direnv/bin/nix-direnv-reload
Executable file
19
.direnv/bin/nix-direnv-reload
Executable file
@@ -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
|
||||||
1
.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
Symbolic link
1
.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
|
||||||
1
.direnv/flake-inputs/j8wb3r6xmck1kwx5yfhgl0dlg8y2qa1b-source
Symbolic link
1
.direnv/flake-inputs/j8wb3r6xmck1kwx5yfhgl0dlg8y2qa1b-source
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/j8wb3r6xmck1kwx5yfhgl0dlg8y2qa1b-source
|
||||||
1
.direnv/flake-inputs/xaknai40sz4yyy5658prwrwmfycj4xvm-source
Symbolic link
1
.direnv/flake-inputs/xaknai40sz4yyy5658prwrwmfycj4xvm-source
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/xaknai40sz4yyy5658prwrwmfycj4xvm-source
|
||||||
1
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
Symbolic link
1
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
|
||||||
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/y0camphrdlb2higdygca6b3sqia1bgf6-nix-shell-env
|
||||||
2156
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
2156
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
File diff suppressed because it is too large
Load Diff
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.DS_Store
|
||||||
|
target
|
||||||
2851
Cargo.lock
generated
Normal file
2851
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
@@ -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"
|
||||||
57
README.md
Normal file
57
README.md
Normal file
@@ -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/<id>/`:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -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
|
||||||
|
}
|
||||||
48
flake.nix
Normal file
48
flake.nix
Normal file
@@ -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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
12
nix/packages/codex-controller-loop.nix
Normal file
12
nix/packages/codex-controller-loop.nix
Normal file
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/app/input.rs
Normal file
20
src/app/input.rs
Normal file
@@ -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<bool> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/app/mod.rs
Normal file
215
src/app/mod.rs
Normal file
@@ -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<SessionEntry>,
|
||||||
|
pub(crate) event_rx: Receiver<AppEvent>,
|
||||||
|
pub(crate) control_tx: Sender<ControlCommand>,
|
||||||
|
pub(crate) session_input_tokens: Option<u64>,
|
||||||
|
pub(crate) session_output_tokens: Option<u64>,
|
||||||
|
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<SessionSelection>,
|
||||||
|
pub(crate) session_drag_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub screen: Screen,
|
||||||
|
pub picker_items: Vec<crate::model::ControllerSummary>,
|
||||||
|
pub picker_selected: usize,
|
||||||
|
pub create_input: String,
|
||||||
|
pub create_error: Option<String>,
|
||||||
|
pub default_task_path: PathBuf,
|
||||||
|
pub(crate) workspace: Option<WorkspaceRuntime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn bootstrap(task_path: Option<PathBuf>) -> Result<Self> {
|
||||||
|
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<crate::model::SessionGroup> {
|
||||||
|
self.workspace
|
||||||
|
.as_ref()
|
||||||
|
.map(|workspace| group_session_entries(&workspace.session_entries))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_status_snapshot(&self) -> Option<StatusSnapshot> {
|
||||||
|
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<CrosstermBackend<std::io::Stdout>>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/app/picker.rs
Normal file
91
src/app/picker.rs
Normal file
@@ -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<bool> {
|
||||||
|
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<bool> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
241
src/app/runtime.rs
Normal file
241
src/app/runtime.rs
Normal file
@@ -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<PathBuf>,
|
||||||
|
) -> 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<String>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/app/session.rs
Normal file
156
src/app/session.rs
Normal file
@@ -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<SessionCursor> {
|
||||||
|
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<String> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
117
src/app/tests.rs
Normal file
117
src/app/tests.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
309
src/app/workspace_input.rs
Normal file
309
src/app/workspace_input.rs
Normal file
@@ -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<bool> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/cli.rs
Normal file
79
src/cli.rs
Normal file
@@ -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<PathBuf>,
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/controller/engine.rs
Normal file
206
src/controller/engine.rs
Normal file
@@ -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<ControlCommand>,
|
||||||
|
event_tx: Sender<AppEvent>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
61
src/controller/executor.rs
Normal file
61
src/controller/executor.rs
Normal file
@@ -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<AppEvent>,
|
||||||
|
) -> Result<ExecutionResponse> {
|
||||||
|
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)?)
|
||||||
|
}
|
||||||
13
src/controller/goal_checker.rs
Normal file
13
src/controller/goal_checker.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::model::{ControllerState, GoalStatus, Plan};
|
||||||
|
|
||||||
|
pub fn is_done(plan: &Plan, state: &ControllerState) -> Result<bool> {
|
||||||
|
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
|
||||||
|
}
|
||||||
6
src/controller/mod.rs
Normal file
6
src/controller/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod engine;
|
||||||
|
pub mod executor;
|
||||||
|
pub mod goal_checker;
|
||||||
|
pub mod planner;
|
||||||
|
pub mod state;
|
||||||
|
pub mod verifier;
|
||||||
50
src/controller/planner.rs
Normal file
50
src/controller/planner.rs
Normal file
@@ -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<AppEvent>,
|
||||||
|
) -> Result<Plan> {
|
||||||
|
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<crate::model::PlanStep> {
|
||||||
|
plan.next_actionable_step(&state.completed_steps)
|
||||||
|
}
|
||||||
9
src/controller/state.rs
Normal file
9
src/controller/state.rs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
44
src/controller/verifier.rs
Normal file
44
src/controller/verifier.rs
Normal file
@@ -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<AppEvent>,
|
||||||
|
) -> Result<VerificationSummary> {
|
||||||
|
process::run_shell_commands(
|
||||||
|
repo_root,
|
||||||
|
&response.verification_commands,
|
||||||
|
event_tx,
|
||||||
|
"Verification",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_cleanup(
|
||||||
|
_config: &TaskConfig,
|
||||||
|
step: &PlanStep,
|
||||||
|
response: &ExecutionResponse,
|
||||||
|
) -> Result<CleanupSummary> {
|
||||||
|
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<AppEvent>,
|
||||||
|
) -> Result<TestSummary> {
|
||||||
|
process::run_shell_commands(repo_root, &response.test_commands, event_tx, "Tests", None)
|
||||||
|
}
|
||||||
7
src/error.rs
Normal file
7
src/error.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ControllerError {
|
||||||
|
#[error("command failed: {0}")]
|
||||||
|
CommandFailed(String),
|
||||||
|
}
|
||||||
17
src/main.rs
Normal file
17
src/main.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
774
src/model.rs
Normal file
774
src/model.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub outputs: Vec<String>,
|
||||||
|
pub dependencies: Vec<String>,
|
||||||
|
pub verification: Vec<VerificationCheck>,
|
||||||
|
pub cleanup_requirements: Vec<CleanupRule>,
|
||||||
|
pub status: StepStatus,
|
||||||
|
pub attempts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Plan {
|
||||||
|
pub version: u32,
|
||||||
|
pub goal_summary: String,
|
||||||
|
pub steps: Vec<PlanStep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PlanStep> {
|
||||||
|
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<String> {
|
||||||
|
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<String>,
|
||||||
|
pub output: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
pub transcript: Vec<PlanningTurn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub iteration: u32,
|
||||||
|
pub replan_required: bool,
|
||||||
|
pub completed_steps: Vec<String>,
|
||||||
|
pub blocked_steps: Vec<String>,
|
||||||
|
pub last_verification: Option<VerificationSummary>,
|
||||||
|
pub last_cleanup_summary: Option<CleanupSummary>,
|
||||||
|
pub last_full_test_summary: Option<TestSummary>,
|
||||||
|
pub history: Vec<HistoryEvent>,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
pub planning_session: PlanningSessionMeta,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
pub last_usage_refresh_at: Option<String>,
|
||||||
|
pub last_usage_input_tokens: Option<u64>,
|
||||||
|
pub last_usage_output_tokens: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
pub goal_md: Option<String>,
|
||||||
|
pub standards_md: Option<String>,
|
||||||
|
pub plan: Option<Plan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ExecutionResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub verification_commands: Vec<String>,
|
||||||
|
pub test_commands: Vec<String>,
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
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<String>,
|
||||||
|
pub completed_steps: usize,
|
||||||
|
pub total_steps: usize,
|
||||||
|
pub last_updated: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
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<String>,
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
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<u64>,
|
||||||
|
pub output_tokens: Option<u64>,
|
||||||
|
pub refreshed_at: Option<String>,
|
||||||
|
pub available: bool,
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UsageSnapshot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::unavailable("usage unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageSnapshot {
|
||||||
|
pub fn unavailable(note: impl Into<String>) -> 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<String>,
|
||||||
|
pub phase: ControllerPhase,
|
||||||
|
pub iteration: u32,
|
||||||
|
pub session_input_tokens: Option<u64>,
|
||||||
|
pub session_output_tokens: Option<u64>,
|
||||||
|
pub usage: UsageSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn group_session_entries(entries: &[SessionEntry]) -> Vec<SessionGroup> {
|
||||||
|
let mut groups: Vec<SessionGroup> = 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::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/planning/forwarder.rs
Normal file
98
src/planning/forwarder.rs
Normal file
@@ -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::<Vec<_>>()
|
||||||
|
.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<PlannerResponse> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/planning/mod.rs
Normal file
2
src/planning/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod forwarder;
|
||||||
|
pub mod session;
|
||||||
79
src/planning/session.rs
Normal file
79
src/planning/session.rs
Normal file
@@ -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<AppEvent>,
|
||||||
|
) -> Result<PlannerResponse> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
604
src/process.rs
Normal file
604
src/process.rs
Normal file
@@ -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<AppEvent>,
|
||||||
|
source: SessionSource,
|
||||||
|
tag: Option<String>,
|
||||||
|
) -> Result<String> {
|
||||||
|
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<String> {
|
||||||
|
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<AppEvent>,
|
||||||
|
title: &str,
|
||||||
|
tag: Option<String>,
|
||||||
|
) -> Result<CommandSummary> {
|
||||||
|
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::<Value>(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<String> {
|
||||||
|
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<String> {
|
||||||
|
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> {
|
||||||
|
string_field(
|
||||||
|
item,
|
||||||
|
&["tool_name", "tool", "name", "server_name", "server"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_text(value: &Value) -> Option<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/repo.rs
Normal file
62
src/repo.rs
Normal file
@@ -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<u64> {
|
||||||
|
timestamp.parse::<u64>().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)
|
||||||
|
}
|
||||||
1
src/storage/mod.rs
Normal file
1
src/storage/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod toon;
|
||||||
388
src/storage/toon.rs
Normal file
388
src/storage/toon.rs
Normal file
@@ -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<TaskConfig> {
|
||||||
|
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<Plan> {
|
||||||
|
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<ControllerState> {
|
||||||
|
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<String> {
|
||||||
|
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<Vec<ControllerSummary>> {
|
||||||
|
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::<String>();
|
||||||
|
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<TaskConfig> {
|
||||||
|
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<Vec<ControllerSummary>> {
|
||||||
|
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::<Plan>(root, controller_id, "plan.toon").unwrap_or_default();
|
||||||
|
let state = read_toon_from_root::<ControllerState>(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<String> {
|
||||||
|
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<T: DeserializeOwned>(
|
||||||
|
root: &Path,
|
||||||
|
controller_id: &str,
|
||||||
|
filename: &str,
|
||||||
|
) -> Result<T> {
|
||||||
|
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<String> {
|
||||||
|
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<T: DeserializeOwned>(path: &Path) -> Result<T> {
|
||||||
|
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<T: Serialize>(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
905
src/ui/mod.rs
Normal file
905
src/ui/mod.rs
Normal file
@@ -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<SessionRenderRow> {
|
||||||
|
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<Line<'static>> {
|
||||||
|
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<String> {
|
||||||
|
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<Line<'static>> {
|
||||||
|
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<u64>) -> 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<String>, 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<Line<'static>>,
|
||||||
|
label: &str,
|
||||||
|
value: impl Into<String>,
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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("│ "));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user