fix: slim down token usage
This commit is contained in:
@@ -1,9 +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"
|
||||
goal_file: ".agent/controllers/teamwise-prompt-lens/goal.md"
|
||||
plan_file: ".agent/controllers/teamwise-prompt-lens/plan.toon"
|
||||
state_file: ".agent/controllers/teamwise-prompt-lens/state.toon"
|
||||
standards_file: ".agent/controllers/teamwise-prompt-lens/standards.md"
|
||||
branch: "codex/teamwise-prompt-lens"
|
||||
continue_until: "fixed-point"
|
||||
max_runs: 12
|
||||
max_wall_clock: 4h
|
||||
@@ -1,6 +1,6 @@
|
||||
version: 1
|
||||
phase: executing
|
||||
goal_status: "in-progress"
|
||||
phase: blocked
|
||||
goal_status: blocked
|
||||
goal_revision: 1
|
||||
current_step_id: null
|
||||
iteration: 0
|
||||
@@ -11,7 +11,7 @@ last_verification: null
|
||||
last_cleanup_summary: null
|
||||
last_full_test_summary: null
|
||||
history[0]:
|
||||
notes[0]:
|
||||
notes[1]: No actionable step remained and autonomous replan produced nothing.
|
||||
planning_session:
|
||||
pending_question: null
|
||||
transcript[4]{role,content}:
|
||||
@@ -20,6 +20,6 @@ planning_session:
|
||||
user,find large files and refactor to smaller files
|
||||
assistant,Planning completed
|
||||
started_at: "1775273562"
|
||||
last_usage_refresh_at: "1775274722"
|
||||
last_usage_refresh_at: "1775275327"
|
||||
last_usage_input_tokens: null
|
||||
last_usage_output_tokens: null
|
||||
5
.agent/controllers/module-mosaic/goal.md
Normal file
5
.agent/controllers/module-mosaic/goal.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Goal
|
||||
|
||||
Refactor the oversized Rust modules in this repository into smaller, focused directory modules without changing runtime behavior, controller flow, or persisted controller file formats.
|
||||
|
||||
Prioritize `src/ui/mod.rs`, `src/model.rs`, `src/process.rs`, `src/storage/toon.rs`, `src/app/workspace_input.rs`, and `src/app/runtime.rs`. End with thin `mod.rs` facades, clear ownership boundaries, stable public APIs or `pub use` reexports where they reduce churn, colocated tests for the moved logic, and a green Rust verification pass.
|
||||
130
.agent/controllers/module-mosaic/plan.toon
Normal file
130
.agent/controllers/module-mosaic/plan.toon
Normal file
@@ -0,0 +1,130 @@
|
||||
version: 6
|
||||
goal_summary: "Refactor the remaining oversized Rust modules into focused directory modules with thin facades, stable public APIs, colocated tests, unchanged controller behavior and persisted formats, then finish with full Rust verification."
|
||||
steps[8]:
|
||||
- id: guardrails
|
||||
title: Add Refactor Guardrails
|
||||
purpose: Lock down the current behavior that must survive file moves before changing module structure.
|
||||
notes: "Completed. Guardrail coverage exists for model, process, storage, app, and UI behavior, including serialized cwd-sensitive TOON tests and the cached session-view shape used by app/UI call sites."
|
||||
inputs[7]: src/model.rs,src/process.rs,src/storage/toon.rs,src/app/tests.rs,src/ui/mod.rs,src/app/session.rs,src/app/mod.rs
|
||||
outputs[5]: Focused tests covering plan/state/schema behavior in the model boundary,"Focused tests covering codex event parsing, usage snapshots, and stderr filtering","Focused tests covering TOON roundtrips, controller discovery, controller id normalization, and cwd-sensitive discovery flows","Focused app tests covering planning command gating, scrolling, and submission behavior","Focused UI tests covering screen rendering, wrapping, and session selection extraction"
|
||||
dependencies[0]:
|
||||
verification[1]:
|
||||
- label: Targeted guardrail tests
|
||||
commands[5]: "cargo test -q model::tests","cargo test -q process::tests","cargo test -q storage::toon::tests","cargo test -q app::tests","cargo test -q ui::tests"
|
||||
cleanup_requirements[2]{label,description}:
|
||||
Colocate new tests,"Keep each new or moved test with the module that owns the behavior instead of adding another catch-all test file."
|
||||
Avoid duplicate fixtures,Reuse existing sample app and model fixtures where possible so the refactor does not create parallel test scaffolding.
|
||||
status: done
|
||||
attempts: 1
|
||||
- id: "model-modules"
|
||||
title: Split Shared Model Types
|
||||
purpose: "Refactor `src/model.rs` into focused submodules while preserving the existing `crate::model::*` surface."
|
||||
notes: "Completed. `src/model.rs` is now a directory facade with focused submodules, stable reexports, and colocated tests protecting schemas, plan helpers, session views, and response types."
|
||||
inputs[1]: src/model.rs
|
||||
outputs[8]: src/model/mod.rs facade reexporting the stable public model API,"src/model/controller.rs for screen, phase, goal status, step status, and controller state types","src/model/plan.rs for task config, plan structs, and plan mutation helpers","src/model/session.rs for session enums, grouping, cursor, cached session-view, and selection helpers",src/model/usage.rs for usage and status snapshot types,"src/model/response.rs for planner, executor, and controller summary response types",src/model/schema.rs for JSON schema builders,Model tests moved beside their owning submodules
|
||||
dependencies[1]: guardrails
|
||||
verification[1]:
|
||||
- label: Model regression tests
|
||||
commands[6]: "cargo test -q model::controller::tests","cargo test -q model::plan::tests","cargo test -q model::session::tests","cargo test -q model::schema::tests","cargo test -q model::usage::tests","cargo test -q"
|
||||
cleanup_requirements[3]{label,description}:
|
||||
Thin facade,Leave `src/model/mod.rs` as a reexport surface rather than reintroducing large inline implementations there.
|
||||
Serde and schema parity,"Keep existing serde attributes, defaults, and JSON schema output unchanged while splitting the code."
|
||||
No stale aliases,"Remove transitional imports or compatibility types that only mirror the old single-file layout once the facade exports are wired."
|
||||
status: done
|
||||
attempts: 3
|
||||
- id: "process-modules"
|
||||
title: Split Process Execution And Parsing
|
||||
purpose: "Refactor `src/process.rs` into focused modules for codex execution, shell execution, usage snapshots, and event parsing."
|
||||
notes: "Completed. `src/process.rs` was replaced with focused modules and a stable facade. Public entry points remain unchanged, targeted parser and usage tests pass, and no new process-specific warnings were introduced."
|
||||
inputs[2]: src/process.rs,src/model/mod.rs
|
||||
outputs[6]: src/process/mod.rs facade preserving the current public functions,src/process/codex.rs for `run_codex_with_schema` and controller id generation,src/process/shell.rs for shell command execution and command summaries,src/process/usage.rs for usage snapshot helpers,src/process/parser.rs for codex JSON line parsing and rendering helpers,Process parsing tests kept with the parser and usage modules
|
||||
dependencies[2]: guardrails,"model-modules"
|
||||
verification[1]:
|
||||
- label: Process regression tests
|
||||
commands[3]: "cargo test -q process::parser::tests","cargo test -q process::usage::tests","cargo test -q"
|
||||
cleanup_requirements[3]{label,description}:
|
||||
Stable process API,"Preserve the current `crate::process::*` entry points so controller and app call sites stay unchanged."
|
||||
No parsing drift,"Keep command, tool, thinking, usage, and stderr event behavior identical after extraction."
|
||||
Cull moved dead code,Delete obsolete inline helpers in the old file instead of leaving duplicate parser or usage logic behind.
|
||||
status: done
|
||||
attempts: 1
|
||||
- id: "storage-toon-modules"
|
||||
title: Split TOON Persistence Helpers
|
||||
purpose: "Refactor `src/storage/toon.rs` into focused persistence, discovery, codec, and id helper modules without changing file formats."
|
||||
notes: "Completed. `src/storage/toon.rs` is now a focused directory module with stable facade exports, shared codec helpers, preserved cwd-sensitive discovery behavior, and passing targeted plus full test runs."
|
||||
inputs[2]: src/storage/toon.rs,src/model/mod.rs
|
||||
outputs[6]: src/storage/toon/mod.rs facade preserving the current storage API,src/storage/toon/files.rs for controller file creation and markdown read/write helpers,src/storage/toon/codec.rs for shared TOON read and write helpers,"src/storage/toon/controllers.rs for controller creation, listing, summaries, discovery, and timestamp helpers","src/storage/toon/ids.rs for normalization, uniqueness, fallback, and suffix helpers","Storage tests split between codec, discovery, and id modules"
|
||||
dependencies[2]: guardrails,"model-modules"
|
||||
verification[1]:
|
||||
- label: Storage regression tests
|
||||
commands[5]: "cargo test -q storage::toon::codec::tests","cargo test -q storage::toon::controllers::tests","cargo test -q storage::toon::ids::tests","cargo test -q storage::toon::tests","cargo test -q"
|
||||
cleanup_requirements[3]{label,description}:
|
||||
No format drift,"Keep persisted markdown and TOON content shape byte-compatible except for harmless formatting already produced by existing helpers."
|
||||
Single codec path,Route all TOON encoding and decoding through shared codec helpers instead of leaving duplicate inline file logic.
|
||||
"Preserve cwd-sensitive tests","Keep the existing cwd mutex and discovery test discipline intact so file-system behavior stays deterministic."
|
||||
status: done
|
||||
attempts: 1
|
||||
- id: "app-runtime-modules"
|
||||
title: Split App Runtime Lifecycle
|
||||
purpose: "Refactor `src/app/runtime.rs` into focused modules for workspace lifecycle, runtime events, and usage refresh while keeping `App` behavior stable."
|
||||
notes: "Completed. `src/app/runtime.rs` now routes through focused runtime modules with stable `impl App` entry points, expanded colocated tests, and a clean repository-wide verification pass after extraction."
|
||||
inputs[5]: src/app/runtime.rs,src/app/mod.rs,src/process/mod.rs,src/storage/toon/mod.rs,src/app/session.rs
|
||||
outputs[5]: src/app/runtime/mod.rs coordinating the runtime submodules,"src/app/runtime/workspace.rs for open, create, load, picker refresh, and shutdown flows",src/app/runtime/events.rs for draining and applying runtime events plus local session entry helpers,src/app/runtime/usage.rs for usage refresh and state persistence,"Expanded app tests covering event application, usage refresh, workspace open flows, and cached-session reconstruction"
|
||||
dependencies[4]: guardrails,"model-modules","process-modules","storage-toon-modules"
|
||||
verification[1]:
|
||||
- label: App runtime regression tests
|
||||
commands[5]: "cargo test -q app::runtime::events::tests","cargo test -q app::runtime::usage::tests","cargo test -q app::runtime::workspace::tests","cargo test -q app::tests","cargo test -q"
|
||||
cleanup_requirements[3]{label,description}:
|
||||
Stable App methods,Keep the current `impl App` method names and external call sites intact while moving their bodies into submodules.
|
||||
Single hydration path,Avoid duplicating workspace state initialization and usage snapshot reconstruction across runtime modules.
|
||||
"Contain runtime-only logic",Do not let runtime extraction leak storage or process implementation details into unrelated app modules.
|
||||
status: done
|
||||
attempts: 1
|
||||
- id: "app-workspace-input-modules"
|
||||
title: Split Workspace Input Handling
|
||||
purpose: "Refactor `src/app/workspace_input.rs` into focused keyboard, mouse, command, and submission modules while preserving interaction behavior."
|
||||
notes: "Next execution step. Runtime seams are now stable, so extract interaction logic by ownership boundary while keeping slash commands, planning-mode gating, drag selection, scroll behavior, follow-output resets, warning text, and submission side effects unchanged. Controller recovered this step from stale active state and returned it to todo."
|
||||
inputs[5]: src/app/workspace_input.rs,src/app/mod.rs,src/app/tests.rs,src/ui/mod.rs,src/app/runtime/mod.rs
|
||||
outputs[6]: src/app/workspace_input/mod.rs coordinating workspace input entry points,"src/app/workspace_input/mouse.rs for selection, drag, and wheel handling",src/app/workspace_input/keyboard.rs for key dispatch and navigation,src/app/workspace_input/commands.rs for slash command handling and planning mode gating,src/app/workspace_input/submission.rs for user message submission and local session entry creation,"Expanded colocated tests covering slash commands, selection, drag, scroll, follow-output reset behavior, and submission ordering"
|
||||
dependencies[2]: guardrails,"app-runtime-modules"
|
||||
verification[1]:
|
||||
- label: Workspace input regression tests
|
||||
commands[4]: "cargo test -q app::workspace_input::commands::tests","cargo test -q app::workspace_input::submission::tests","cargo test -q app::tests","cargo test -q"
|
||||
cleanup_requirements[3]{label,description}:
|
||||
Keep command strings stable,"Do not change existing slash commands, warning text, or planning-mode restrictions during the split."
|
||||
No duplicated reset logic,"Centralize selection and follow-output resets instead of copying the same workspace cleanup code into each input module."
|
||||
Preserve local entry creation,Keep local session entry generation and submission ordering identical so UI and persistence behavior do not drift.
|
||||
status: active
|
||||
attempts: 1
|
||||
- id: "ui-modules"
|
||||
title: Split UI Rendering Helpers
|
||||
purpose: Refactor `src/ui/mod.rs` into focused rendering modules while preserving the current exported helpers and TUI behavior.
|
||||
notes: "Start after workspace input extraction settles the app-facing seams. Keep `src/ui/mod.rs` as a thin facade, preserve wrapping and selection math exactly, and remove the dead `session_row_cells` helper or relocate its behavior into the owning session-rendering module so no dead-code warning survives."
|
||||
inputs[5]: src/ui/mod.rs,src/ui/scroll.rs,src/app/mod.rs,src/model/mod.rs,src/app/workspace_input/mod.rs
|
||||
outputs[9]: src/ui/mod.rs thin facade exporting the stable UI entry points,"src/ui/theme.rs for colors, shared styles, and shell block helpers","src/ui/layout.rs for `WorkspaceLayout`, `SessionView`, and layout calculations",src/ui/picker.rs for controller picker rendering,"src/ui/create_controller.rs for create-controller screen rendering",src/ui/workspace.rs for workspace screen orchestration,"src/ui/session.rs for session row rendering, wrapping, and selection extraction helpers","src/ui/sidebar.rs for plan board, status line, and composer helper rendering",UI tests updated to target the new owning modules without changing rendered behavior
|
||||
dependencies[4]: guardrails,"model-modules","app-runtime-modules","app-workspace-input-modules"
|
||||
verification[1]:
|
||||
- label: UI regression tests
|
||||
commands[4]: "cargo test -q ui::layout::tests","cargo test -q ui::session::tests","cargo test -q ui::tests","cargo test -q"
|
||||
cleanup_requirements[3]{label,description}:
|
||||
Thin facade,Keep `src/ui/mod.rs` limited to module wiring and stable reexports instead of leaving business logic there.
|
||||
Preserve selection math,"Keep wrapping, selection clipping, and copied session text behavior identical after extraction."
|
||||
Remove refactor leftovers,Delete dead rendering helpers and stale imports introduced or exposed by the module split.
|
||||
status: todo
|
||||
attempts: 0
|
||||
- id: "final-integration"
|
||||
title: Run Final Cleanup And Verification
|
||||
purpose: "Reconcile imports and module wiring, remove leftover compatibility code, and run the full repository quality gate."
|
||||
notes: "Final pass after the remaining app and UI splits. Clean wiring and refactor leftovers, confirm that stable APIs remain intact, and rerun the full required verification set after any final lint-driven cleanup that does not change behavior."
|
||||
inputs[7]: src/model/mod.rs,src/process/mod.rs,src/storage/toon/mod.rs,src/app/runtime/mod.rs,src/app/workspace_input/mod.rs,src/ui/mod.rs,Cargo.toml
|
||||
outputs[3]: Updated module declarations and imports across `src/`,"Removed dead helpers, stale imports, and compatibility shims left from the large-file split","Green formatting, test, and clippy verification for the refactor"
|
||||
dependencies[6]: "model-modules","process-modules","storage-toon-modules","app-runtime-modules","app-workspace-input-modules","ui-modules"
|
||||
verification[1]:
|
||||
- label: Full verification
|
||||
commands[3]: "cargo fmt --check","cargo test -q","cargo clippy -q --all-targets --all-features"
|
||||
cleanup_requirements[3]{label,description}:
|
||||
Remove leftovers,Delete obsolete inline helpers and transitional reexports once the new module structure is wired in cleanly.
|
||||
Keep structure intentional,"Do not leave empty modules or one-off directories after the refactor is complete."
|
||||
No warning regressions,"Do not introduce new dead-code or stale-import warnings as part of the refactor cleanup."
|
||||
status: todo
|
||||
attempts: 0
|
||||
9
.agent/controllers/module-mosaic/standards.md
Normal file
9
.agent/controllers/module-mosaic/standards.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Standards
|
||||
|
||||
- Preserve existing behavior, controller orchestration, TUI interactions, and on-disk `.md` and `.toon` controller formats throughout the refactor.
|
||||
- Prefer focused directory modules when a file mixes responsibilities or grows past roughly 300 lines, and keep `mod.rs` files as thin facades or reexport surfaces.
|
||||
- Keep public call sites stable unless a narrower API is clearly better, using `pub use` reexports to avoid unnecessary churn.
|
||||
- Split code by ownership boundary: model/state/schema concerns, process execution and parsing, TOON persistence and controller discovery, app runtime lifecycle, workspace input handling, and UI rendering helpers.
|
||||
- Move or add focused tests with the code they protect, especially around model schemas, session grouping and selection, process parsing, storage discovery and id generation, runtime event handling, workspace commands, and UI rendering helpers.
|
||||
- Remove dead helpers, stale imports, and compatibility layers that only mirror the old file layout.
|
||||
- Finish with `cargo fmt --check`, `cargo test -q`, and `cargo clippy -q --all-targets --all-features` passing.
|
||||
43
.agent/controllers/module-mosaic/state.toon
Normal file
43
.agent/controllers/module-mosaic/state.toon
Normal file
@@ -0,0 +1,43 @@
|
||||
version: 1
|
||||
phase: executing
|
||||
stop_reason: null
|
||||
goal_status: "in-progress"
|
||||
goal_revision: 2
|
||||
current_step_id: null
|
||||
iteration: 7
|
||||
replan_required: false
|
||||
completed_steps[5]: guardrails,"model-modules","process-modules","storage-toon-modules","app-runtime-modules"
|
||||
blocked_steps[0]:
|
||||
last_verification:
|
||||
passed: true
|
||||
summary: All commands passed
|
||||
commands[3]: "cargo fmt --check","cargo test -q","cargo clippy -q --all-targets --all-features"
|
||||
output[1]: "running 65 tests\n.................................................................\ntest result: ok. 65 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s"
|
||||
last_cleanup_summary:
|
||||
passed: true
|
||||
summary: "Cleanup accepted for app-runtime-modules"
|
||||
commands[0]:
|
||||
output[3]: Kept runtime behavior stable by preserving the existing `impl App` entry points in `src/app/runtime/mod.rs` and moving only the implementation bodies behind module boundaries.,"Added focused runtime guardrail coverage in `src/app/runtime/events.rs`, `src/app/runtime/usage.rs`, and `src/app/runtime/workspace.rs` instead of expanding the catch-all app test file.","Folded in low-risk cleanup needed for a clean verification pass: collapsed the app event-poll branch in `src/app/mod.rs`, elided a needless lifetime in `src/process/parser.rs`, marked the UI-only helper in `src/ui/mod.rs` as test-only, and explicitly allowed the existing `AppEvent` enum layout instead of changing runtime payload behavior."
|
||||
last_full_test_summary:
|
||||
passed: true
|
||||
summary: All commands passed
|
||||
commands[4]: "cargo test -q app::runtime::events::tests","cargo test -q app::runtime::usage::tests","cargo test -q app::runtime::workspace::tests","cargo test -q app::tests"
|
||||
output[4]: "running 3 tests\n...\ntest result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 62 filtered out; finished in 0.00s","running 2 tests\n..\ntest result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 63 filtered out; finished in 0.00s","running 1 test\n.\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 64 filtered out; finished in 0.00s","running 6 tests\n......\ntest result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 59 filtered out; finished in 0.00s"
|
||||
history[5]{timestamp,kind,detail}:
|
||||
"1775277691","step-complete",Completed guardrails
|
||||
"1775278850","step-complete","Completed model-modules"
|
||||
"1775279170","step-complete","Completed process-modules"
|
||||
"1775279529","step-complete","Completed storage-toon-modules"
|
||||
"1775279938","step-complete","Completed app-runtime-modules"
|
||||
notes[8]: No actionable step remained and autonomous replan produced nothing.,"Recovered stale active step state for module-mosaic. Reset model-modules to todo.","Recovered stale active step state for module-mosaic. Reset model-modules to todo.",No actionable step remained and autonomous replan produced nothing.,"Recovered stale active step state for module-mosaic. Reset process-modules to todo.","Recovered stale active step state for module-mosaic. Reset storage-toon-modules to todo.","Recovered stale active step state for module-mosaic. Reset app-runtime-modules to todo.","Recovered stale active step state for module-mosaic. Reset app-workspace-input-modules to todo."
|
||||
planning_session:
|
||||
pending_question: null
|
||||
transcript[4]{role,content}:
|
||||
user,refactor large files to smaller more maintainable files
|
||||
assistant,Planning completed
|
||||
user,refactor large files
|
||||
assistant,Planning completed
|
||||
started_at: "1775275504"
|
||||
last_usage_refresh_at: "1775280021"
|
||||
last_usage_input_tokens: null
|
||||
last_usage_output_tokens: null
|
||||
3
.agent/controllers/runtime-open/goal.md
Normal file
3
.agent/controllers/runtime-open/goal.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Goal
|
||||
|
||||
Describe the goal for this controller.
|
||||
3
.agent/controllers/runtime-open/plan.toon
Normal file
3
.agent/controllers/runtime-open/plan.toon
Normal file
@@ -0,0 +1,3 @@
|
||||
version: 1
|
||||
goal_summary: No plan yet
|
||||
steps[0]:
|
||||
5
.agent/controllers/runtime-open/standards.md
Normal file
5
.agent/controllers/runtime-open/standards.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Standards
|
||||
|
||||
- Keep code maintainable.
|
||||
- Avoid one-off hacks.
|
||||
- Leave tests green.
|
||||
22
.agent/controllers/runtime-open/state.toon
Normal file
22
.agent/controllers/runtime-open/state.toon
Normal file
@@ -0,0 +1,22 @@
|
||||
version: 1
|
||||
phase: planning
|
||||
stop_reason: null
|
||||
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: null
|
||||
last_usage_refresh_at: null
|
||||
last_usage_input_tokens: null
|
||||
last_usage_output_tokens: null
|
||||
12
.agent/controllers/teamwise-prompt-lens/goal.md
Normal file
12
.agent/controllers/teamwise-prompt-lens/goal.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Goal
|
||||
|
||||
Turn rough user prompts entered into the controller goal planner into clear, production-quality improvement briefs shaped by a cross-functional software team.
|
||||
|
||||
The controller should:
|
||||
- reinterpret ambiguous or sloppy requests through the perspectives of an architect, product owner, senior engineer, QA engineer, and other relevant software roles;
|
||||
- surface missing context, risks, constraints, edge cases, and acceptance criteria before execution begins;
|
||||
- rewrite the original request into a coherent codebase-improvement prompt that is specific, technically credible, and ready for autonomous planning or implementation;
|
||||
- prefer maintainable, incremental improvements over novelty or one-off solutions;
|
||||
- produce outputs that help downstream agents make sound architectural, implementation, testing, and rollout decisions with minimal back-and-forth.
|
||||
|
||||
Success means a weak initial prompt becomes a well-scoped, team-reviewed execution brief with explicit goals, assumptions, constraints, risks, and verification expectations.
|
||||
89
.agent/controllers/teamwise-prompt-lens/plan.toon
Normal file
89
.agent/controllers/teamwise-prompt-lens/plan.toon
Normal file
@@ -0,0 +1,89 @@
|
||||
version: 1
|
||||
goal_summary: "Define a team-oriented planning controller that transforms rough prompts into implementation-ready improvement briefs using cross-functional software perspectives."
|
||||
steps[6]:
|
||||
- id: "step-01"
|
||||
title: Audit Current Controller Artifacts
|
||||
purpose: "Inspect the existing goal, standards, plan, and state files to replace placeholders and preserve any useful structure."
|
||||
notes: "The current controller files are placeholder-heavy and need concrete intent before automation can rely on them. One or more commands failed"
|
||||
inputs[4]: ".agent/controllers/teamwise-prompt-lens/goal.md",".agent/controllers/teamwise-prompt-lens/standards.md",".agent/controllers/teamwise-prompt-lens/plan.toon",".agent/controllers/teamwise-prompt-lens/state.toon"
|
||||
outputs[3]: Confirmed file inventory,List of placeholder content to replace,Any existing TOON structure worth preserving
|
||||
dependencies[0]:
|
||||
verification[1]:
|
||||
- label: Read current controller files
|
||||
commands[4]: "sed -n '1,200p' .agent/controllers/teamwise-prompt-lens/goal.md","sed -n '1,240p' .agent/controllers/teamwise-prompt-lens/standards.md","sed -n '1,240p' .agent/controllers/teamwise-prompt-lens/plan.toon","sed -n '1,240p' .agent/controllers/teamwise-prompt-lens/state.toon"
|
||||
cleanup_requirements[1]{label,description}:
|
||||
No stale placeholders,Remove generic placeholder text once the real controller intent is documented.
|
||||
status: active
|
||||
attempts: 4
|
||||
- id: "step-02"
|
||||
title: "Define Cross-Functional Prompt Lens"
|
||||
purpose: "Specify the software-team roles and the exact review dimensions each role contributes to prompt improvement."
|
||||
notes: The controller needs explicit personas so it consistently upgrades prompts instead of producing generic rewrites.
|
||||
inputs[2]: "User request for architect, QA, senior engineer, product owner, and broader team input","Findings from step-01"
|
||||
outputs[3]: Role list for the prompt lens,"Per-role review criteria",Rules for when to include or omit additional roles
|
||||
dependencies[1]: "step-01"
|
||||
verification[1]:
|
||||
- label: Check role coverage in artifacts
|
||||
commands[1]: "rg -n \"architect|product|senior engineer|qa|operations|security|performance\" .agent/controllers/teamwise-prompt-lens"
|
||||
cleanup_requirements[1]{label,description}:
|
||||
Avoid role sprawl,Keep the persona set opinionated and reusable rather than listing every possible specialty.
|
||||
status: todo
|
||||
attempts: 0
|
||||
- id: "step-03"
|
||||
title: Rewrite Goal And Standards
|
||||
purpose: "Replace the placeholder Markdown with controller-specific guidance that matches the desired teamwise prompt transformation behavior."
|
||||
notes: The goal and standards must be explicit because downstream planning quality depends on them.
|
||||
inputs[2]: "Outputs from step-01","Outputs from step-02"
|
||||
outputs[2]: "Updated .agent/controllers/teamwise-prompt-lens/goal.md","Updated .agent/controllers/teamwise-prompt-lens/standards.md"
|
||||
dependencies[1]: "step-02"
|
||||
verification[1]:
|
||||
- label: Validate rewritten Markdown content
|
||||
commands[3]: "sed -n '1,240p' .agent/controllers/teamwise-prompt-lens/goal.md","sed -n '1,260p' .agent/controllers/teamwise-prompt-lens/standards.md","rg -n \"Describe the goal for this controller|placeholder|TODO\" .agent/controllers/teamwise-prompt-lens/goal.md .agent/controllers/teamwise-prompt-lens/standards.md"
|
||||
cleanup_requirements[1]{label,description}:
|
||||
Keep standards actionable,Remove vague quality slogans unless they imply a concrete execution rule.
|
||||
status: todo
|
||||
attempts: 0
|
||||
- id: "step-04"
|
||||
title: Author Planner Workflow In TOON
|
||||
purpose: "Encode the planning workflow so the controller consistently turns sloppy prompts into structured, execution-ready briefs."
|
||||
notes: The main behavioral logic belongs in the plan file because the controller is operating in planning mode.
|
||||
inputs[2]: Rewritten goal and standards,"Cross-functional prompt lens definition"
|
||||
outputs[2]: "Updated .agent/controllers/teamwise-prompt-lens/plan.toon with ordered planning behavior","Explicit output sections for rewritten prompt, assumptions, risks, acceptance criteria, and verification"
|
||||
dependencies[1]: "step-03"
|
||||
verification[1]:
|
||||
- label: Review plan structure
|
||||
commands[2]: "sed -n '1,260p' .agent/controllers/teamwise-prompt-lens/plan.toon","rg -n \"assumptions|risks|acceptance criteria|verification|rewrite|team\" .agent/controllers/teamwise-prompt-lens/plan.toon"
|
||||
cleanup_requirements[1]{label,description}:
|
||||
No dead branches,Remove unused workflow branches or duplicate instructions that would confuse autonomous execution.
|
||||
status: todo
|
||||
attempts: 0
|
||||
- id: "step-05"
|
||||
title: Initialize Stateful Planning Data
|
||||
purpose: "Define the minimal controller state needed to track prompt quality, assumptions, open questions, and plan readiness across runs."
|
||||
notes: State should stay minimal so the controller remains predictable and maintainable.
|
||||
inputs[2]: "Planner workflow from step-04","Existing .agent/controllers/teamwise-prompt-lens/state.toon"
|
||||
outputs[2]: "Updated .agent/controllers/teamwise-prompt-lens/state.toon","Stable state fields for prompt intake, role synthesis, assumptions, risks, and completion status"
|
||||
dependencies[1]: "step-04"
|
||||
verification[1]:
|
||||
- label: Inspect state schema
|
||||
commands[2]: "sed -n '1,240p' .agent/controllers/teamwise-prompt-lens/state.toon","rg -n \"prompt|assumption|risk|question|ready|status\" .agent/controllers/teamwise-prompt-lens/state.toon"
|
||||
cleanup_requirements[1]{label,description}:
|
||||
Avoid overspecified state,Remove transient or redundant fields that do not support repeated planning runs.
|
||||
status: todo
|
||||
attempts: 0
|
||||
- id: "step-06"
|
||||
title: Validate With Representative Prompt Cases
|
||||
purpose: Check that the controller can upgrade rough prompts into clearer briefs without losing user intent.
|
||||
notes: A few realistic examples are the fastest way to catch missing sections or overcomplicated output rules.
|
||||
inputs[2]: Updated controller artifacts,Representative sloppy prompts about codebase improvements
|
||||
outputs[2]: Validation notes,"Any final wording adjustments to goal, standards, plan, or state"
|
||||
dependencies[1]: "step-05"
|
||||
verification[2]:
|
||||
- label: Run artifact review against sample prompts
|
||||
commands[4]: "sed -n '1,260p' .agent/controllers/teamwise-prompt-lens/goal.md","sed -n '1,260p' .agent/controllers/teamwise-prompt-lens/standards.md","sed -n '1,320p' .agent/controllers/teamwise-prompt-lens/plan.toon","sed -n '1,260p' .agent/controllers/teamwise-prompt-lens/state.toon"
|
||||
- label: Final placeholder sweep
|
||||
commands[1]: "rg -n \"TODO|placeholder|Describe the goal for this controller|TBD\" .agent/controllers/teamwise-prompt-lens"
|
||||
cleanup_requirements[1]{label,description}:
|
||||
Remove ad hoc examples,"Do not leave validation-only sample prompts in production controller files unless intentionally documented."
|
||||
status: todo
|
||||
attempts: 0
|
||||
13
.agent/controllers/teamwise-prompt-lens/standards.md
Normal file
13
.agent/controllers/teamwise-prompt-lens/standards.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Standards
|
||||
|
||||
- Treat every incoming prompt as incomplete until assumptions, constraints, and success criteria are made explicit.
|
||||
- Synthesize perspectives from architecture, product, engineering, QA, and operations when they materially affect the outcome.
|
||||
- Optimize for maintainable codebase improvements, not clever one-off patches.
|
||||
- Preserve the user's core intent while upgrading precision, scope control, and technical quality.
|
||||
- Make missing information visible as assumptions or open questions instead of silently inventing product or system behavior.
|
||||
- Require clear deliverables, acceptance criteria, and verification expectations in the rewritten prompt.
|
||||
- Call out risks, dependencies, migration concerns, and likely regression areas when relevant.
|
||||
- Keep outputs concise enough for autonomous execution, but complete enough to avoid avoidable follow-up.
|
||||
- Prefer incremental, reviewable changes that can keep tests green throughout execution.
|
||||
- Eliminate placeholder language, vague directives, and non-actionable advice from controller artifacts.
|
||||
- Leave tests green.
|
||||
28
.agent/controllers/teamwise-prompt-lens/state.toon
Normal file
28
.agent/controllers/teamwise-prompt-lens/state.toon
Normal file
File diff suppressed because one or more lines are too long
105
src/app/mod.rs
105
src/app/mod.rs
@@ -7,13 +7,14 @@ mod workspace_input;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyEvent},
|
||||
event::{self, Event},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
@@ -21,14 +22,15 @@ 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,
|
||||
ControllerState, Plan, Screen, SessionEntry, SessionSelection, StatusSnapshot, TaskConfig,
|
||||
UsageSnapshot,
|
||||
};
|
||||
use crate::ui;
|
||||
use crate::ui::{self, scroll::VerticalScrollState, SessionRenderRow, SessionView, SidebarView};
|
||||
|
||||
pub(crate) const USAGE_REFRESH_INTERVAL: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum AppEvent {
|
||||
Session(SessionEntry),
|
||||
Snapshot {
|
||||
@@ -66,9 +68,15 @@ pub(crate) struct WorkspaceRuntime {
|
||||
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_scrollbar: VerticalScrollState,
|
||||
pub(crate) session_rows: Vec<SessionRenderRow>,
|
||||
pub(crate) session_view: Option<SessionView>,
|
||||
pub(crate) session_view_area: ratatui::layout::Rect,
|
||||
pub(crate) sidebar_follow_output: bool,
|
||||
pub(crate) sidebar_scrollbar: VerticalScrollState,
|
||||
pub(crate) sidebar_view: Option<SidebarView>,
|
||||
pub(crate) sidebar_view_area: ratatui::layout::Rect,
|
||||
pub(crate) session_selection: Option<SessionSelection>,
|
||||
pub(crate) session_drag_active: bool,
|
||||
}
|
||||
@@ -80,6 +88,7 @@ pub struct App {
|
||||
pub create_input: String,
|
||||
pub create_error: Option<String>,
|
||||
pub default_task_path: PathBuf,
|
||||
pub(crate) frame_tick: u64,
|
||||
pub(crate) workspace: Option<WorkspaceRuntime>,
|
||||
}
|
||||
|
||||
@@ -93,6 +102,7 @@ impl App {
|
||||
create_input: String::new(),
|
||||
create_error: None,
|
||||
default_task_path: default_task_path.clone(),
|
||||
frame_tick: 0,
|
||||
workspace: None,
|
||||
};
|
||||
|
||||
@@ -108,7 +118,11 @@ impl App {
|
||||
pub fn run(&mut self) -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = std::io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture)?;
|
||||
execute!(
|
||||
stdout,
|
||||
EnterAlternateScreen,
|
||||
crossterm::event::EnableMouseCapture
|
||||
)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
@@ -126,13 +140,6 @@ impl App {
|
||||
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 {
|
||||
@@ -163,6 +170,28 @@ impl App {
|
||||
.and_then(|workspace| workspace.session_selection.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_session_rows(&self) -> Option<&[SessionRenderRow]> {
|
||||
self.workspace
|
||||
.as_ref()
|
||||
.map(|workspace| workspace.session_rows.as_slice())
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_session_view(&self) -> Option<&SessionView> {
|
||||
self.workspace
|
||||
.as_ref()
|
||||
.and_then(|workspace| workspace.session_view.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_sidebar_view(&self) -> Option<&SidebarView> {
|
||||
self.workspace
|
||||
.as_ref()
|
||||
.and_then(|workspace| workspace.sidebar_view.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn heartbeat_frame(&self) -> u64 {
|
||||
self.frame_tick
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_session_scroll(&self) -> usize {
|
||||
let Some(workspace) = self.workspace.as_ref() else {
|
||||
return 0;
|
||||
@@ -170,18 +199,18 @@ impl App {
|
||||
|
||||
let max_scroll = self
|
||||
.workspace_session_line_count()
|
||||
.saturating_sub(workspace.session_viewport_lines);
|
||||
.saturating_sub(workspace.session_scrollbar.viewport_lines);
|
||||
if workspace.session_follow_output {
|
||||
max_scroll
|
||||
} else {
|
||||
workspace.session_scroll.min(max_scroll)
|
||||
workspace.session_scrollbar.position_lines.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))
|
||||
.map(|workspace| workspace.session_scrollbar.content_lines)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -192,24 +221,42 @@ impl App {
|
||||
loop {
|
||||
self.drain_workspace_events()?;
|
||||
self.maybe_refresh_usage()?;
|
||||
self.update_workspace_viewport(terminal.size()?.height as usize);
|
||||
self.update_workspace_viewport(terminal.size()?.into());
|
||||
self.tick_session_scroll_repeat();
|
||||
self.frame_tick = self.frame_tick.wrapping_add(1);
|
||||
|
||||
terminal.backend_mut().write_all(b"\x1b[?2026h")?;
|
||||
terminal.draw(|frame| ui::render(frame, self))?;
|
||||
terminal.backend_mut().write_all(b"\x1b[?2026l")?;
|
||||
terminal.backend_mut().flush()?;
|
||||
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
if event::poll(Duration::from_millis(50))? && self.handle_pending_events(terminal)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_pending_events(
|
||||
&mut self,
|
||||
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||
) -> Result<bool> {
|
||||
loop {
|
||||
match event::read()? {
|
||||
Event::Key(key) => {
|
||||
if self.handle_key(key)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse) => self.handle_mouse(mouse, terminal.size()?.into())?,
|
||||
Event::Resize(_, _) => self.update_workspace_viewport(terminal.size()?.into()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !event::poll(Duration::ZERO)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
299
src/app/runtime/events.rs
Normal file
299
src/app/runtime/events.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::app::{App, AppEvent, WorkspaceRuntime};
|
||||
use crate::model::{
|
||||
group_session_entries, ControllerState, SessionEntry, SessionSource, SessionStream,
|
||||
};
|
||||
use crate::repo;
|
||||
use crate::ui::{self, SessionRenderRow};
|
||||
|
||||
use super::usage;
|
||||
|
||||
pub(super) fn drain_workspace_events(app: &mut App) -> Result<()> {
|
||||
let Some(workspace) = app.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 {
|
||||
apply_event(app, event);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn push_local_entry(
|
||||
app: &mut App,
|
||||
source: SessionSource,
|
||||
stream: SessionStream,
|
||||
title: &str,
|
||||
tag: Option<String>,
|
||||
body: &str,
|
||||
) {
|
||||
let Some(workspace) = app.workspace.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.session_follow_output = true;
|
||||
reset_session_interaction(workspace);
|
||||
workspace.session_entries.push(SessionEntry {
|
||||
source,
|
||||
stream,
|
||||
title: title.to_string(),
|
||||
tag,
|
||||
body: body.to_string(),
|
||||
run_id: repo::next_run_id(),
|
||||
});
|
||||
workspace.session_rows = rebuild_session_rows(&workspace.session_entries);
|
||||
workspace.session_view = None;
|
||||
}
|
||||
|
||||
pub(super) fn rebuild_session_rows(entries: &[SessionEntry]) -> Vec<SessionRenderRow> {
|
||||
ui::build_session_render_rows(&group_session_entries(entries))
|
||||
}
|
||||
|
||||
fn apply_event(app: &mut App, event: AppEvent) {
|
||||
let Some(workspace) = app.workspace.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match event {
|
||||
AppEvent::Session(entry) => {
|
||||
let should_keep_following = workspace.session_follow_output
|
||||
|| workspace.session_scrollbar.position_lines
|
||||
>= workspace.session_scrollbar.scroll_range().saturating_sub(1);
|
||||
reset_session_interaction(workspace);
|
||||
workspace.session_entries.push(entry);
|
||||
workspace.session_rows = rebuild_session_rows(&workspace.session_entries);
|
||||
workspace.session_view = None;
|
||||
workspace.session_follow_output = should_keep_following;
|
||||
}
|
||||
AppEvent::Snapshot {
|
||||
goal_md,
|
||||
standards_md,
|
||||
plan,
|
||||
state,
|
||||
} => apply_snapshot(workspace, goal_md, standards_md, plan, state),
|
||||
AppEvent::CodexUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
} => {
|
||||
*workspace.session_input_tokens.get_or_insert(0) += input_tokens;
|
||||
*workspace.session_output_tokens.get_or_insert(0) += output_tokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_snapshot(
|
||||
workspace: &mut WorkspaceRuntime,
|
||||
goal_md: String,
|
||||
standards_md: String,
|
||||
plan: crate::model::Plan,
|
||||
state: ControllerState,
|
||||
) {
|
||||
let should_keep_following_sidebar = workspace.sidebar_follow_output
|
||||
|| workspace.sidebar_scrollbar.position_lines
|
||||
>= workspace.sidebar_scrollbar.scroll_range().saturating_sub(1);
|
||||
let has_usage = usage::state_has_usage(&state);
|
||||
|
||||
workspace.goal_md = goal_md;
|
||||
workspace.standards_md = standards_md;
|
||||
workspace.plan = plan;
|
||||
workspace.state = state.clone();
|
||||
workspace.sidebar_view = None;
|
||||
workspace.sidebar_follow_output = should_keep_following_sidebar;
|
||||
|
||||
if has_usage {
|
||||
workspace.usage_snapshot = usage::usage_snapshot_from_state(&state);
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_session_interaction(workspace: &mut WorkspaceRuntime) {
|
||||
workspace.session_selection = None;
|
||||
workspace.session_drag_active = false;
|
||||
workspace.session_scrollbar.stop_arrow_hold();
|
||||
workspace.session_scrollbar.stop_drag();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::app::{ControlCommand, WorkspaceRuntime};
|
||||
use crate::cli::DEFAULT_TASK_CONFIG_PATH;
|
||||
use crate::model::{
|
||||
ControllerPhase, ControllerState, Plan, Screen, SessionCursor, SessionSelection,
|
||||
UsageSnapshot,
|
||||
};
|
||||
use crate::ui::{
|
||||
scroll::VerticalScrollState, SessionView, SessionViewMetrics, SidebarView,
|
||||
SidebarViewMetrics,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sample_app() -> (App, mpsc::Sender<AppEvent>) {
|
||||
let (event_tx, event_rx) = mpsc::channel();
|
||||
let (control_tx, _control_rx) = mpsc::channel::<ControlCommand>();
|
||||
let mut workspace = WorkspaceRuntime {
|
||||
task_config: crate::model::TaskConfig::default_for("alpha"),
|
||||
goal_md: "goal".to_string(),
|
||||
standards_md: "standards".to_string(),
|
||||
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_follow_output: false,
|
||||
session_scrollbar: VerticalScrollState::new(false),
|
||||
session_rows: Vec::new(),
|
||||
session_view: Some(SessionView {
|
||||
metrics: SessionViewMetrics {
|
||||
text_rect: ratatui::layout::Rect::default(),
|
||||
scrollbar_rect: ratatui::layout::Rect::default(),
|
||||
total_lines: 0,
|
||||
has_scrollbar: false,
|
||||
},
|
||||
lines: Vec::new(),
|
||||
}),
|
||||
session_view_area: ratatui::layout::Rect::default(),
|
||||
sidebar_follow_output: false,
|
||||
sidebar_scrollbar: VerticalScrollState::new(false),
|
||||
sidebar_view: Some(SidebarView {
|
||||
metrics: SidebarViewMetrics {
|
||||
text_rect: ratatui::layout::Rect::default(),
|
||||
scrollbar_rect: ratatui::layout::Rect::default(),
|
||||
total_lines: 0,
|
||||
has_scrollbar: false,
|
||||
},
|
||||
lines: Vec::new(),
|
||||
}),
|
||||
sidebar_view_area: ratatui::layout::Rect::default(),
|
||||
session_selection: Some(SessionSelection {
|
||||
anchor: SessionCursor { line: 0, column: 0 },
|
||||
focus: SessionCursor { line: 0, column: 4 },
|
||||
}),
|
||||
session_drag_active: true,
|
||||
};
|
||||
workspace.session_scrollbar.set_content_viewport(8, 3);
|
||||
workspace.sidebar_scrollbar.set_content_viewport(8, 3);
|
||||
|
||||
(
|
||||
App {
|
||||
screen: Screen::Workspace,
|
||||
picker_items: Vec::new(),
|
||||
picker_selected: 0,
|
||||
create_input: String::new(),
|
||||
create_error: None,
|
||||
default_task_path: PathBuf::from(DEFAULT_TASK_CONFIG_PATH),
|
||||
frame_tick: 0,
|
||||
workspace: Some(workspace),
|
||||
},
|
||||
event_tx,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_events_clear_selection_and_rebuild_rows() {
|
||||
let (mut app, event_tx) = sample_app();
|
||||
{
|
||||
let workspace = app.workspace.as_mut().expect("workspace");
|
||||
workspace.session_follow_output = false;
|
||||
workspace.session_scrollbar.position_lines = 0;
|
||||
}
|
||||
|
||||
event_tx
|
||||
.send(AppEvent::Session(SessionEntry {
|
||||
source: SessionSource::Planner,
|
||||
stream: SessionStream::Stdout,
|
||||
title: "Plan".to_string(),
|
||||
tag: Some("alpha".to_string()),
|
||||
body: "first line\nsecond line".to_string(),
|
||||
run_id: 7,
|
||||
}))
|
||||
.expect("send session event");
|
||||
|
||||
drain_workspace_events(&mut app).expect("drain events");
|
||||
|
||||
let workspace = app.workspace.as_ref().expect("workspace");
|
||||
assert_eq!(workspace.session_entries.len(), 1);
|
||||
assert!(workspace.session_selection.is_none());
|
||||
assert!(!workspace.session_drag_active);
|
||||
assert!(!workspace.session_follow_output);
|
||||
assert!(workspace.session_view.is_none());
|
||||
assert!(!workspace.session_rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_events_refresh_sidebar_and_cached_usage() {
|
||||
let (mut app, event_tx) = sample_app();
|
||||
{
|
||||
let workspace = app.workspace.as_mut().expect("workspace");
|
||||
workspace.sidebar_scrollbar.position_lines =
|
||||
workspace.sidebar_scrollbar.scroll_range().saturating_sub(1);
|
||||
}
|
||||
|
||||
let state = ControllerState {
|
||||
phase: ControllerPhase::Blocked,
|
||||
last_usage_refresh_at: Some("123".to_string()),
|
||||
last_usage_input_tokens: Some(21),
|
||||
last_usage_output_tokens: Some(34),
|
||||
..ControllerState::default()
|
||||
};
|
||||
event_tx
|
||||
.send(AppEvent::Snapshot {
|
||||
goal_md: "updated goal".to_string(),
|
||||
standards_md: "updated standards".to_string(),
|
||||
plan: Plan::default(),
|
||||
state,
|
||||
})
|
||||
.expect("send snapshot event");
|
||||
|
||||
drain_workspace_events(&mut app).expect("drain events");
|
||||
|
||||
let workspace = app.workspace.as_ref().expect("workspace");
|
||||
assert_eq!(workspace.goal_md, "updated goal");
|
||||
assert_eq!(workspace.standards_md, "updated standards");
|
||||
assert!(workspace.sidebar_follow_output);
|
||||
assert!(workspace.sidebar_view.is_none());
|
||||
assert_eq!(workspace.usage_snapshot.input_tokens, Some(21));
|
||||
assert_eq!(workspace.usage_snapshot.output_tokens, Some(34));
|
||||
assert_eq!(
|
||||
workspace.usage_snapshot.refreshed_at.as_deref(),
|
||||
Some("123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_usage_events_accumulate_session_totals() {
|
||||
let (mut app, event_tx) = sample_app();
|
||||
|
||||
event_tx
|
||||
.send(AppEvent::CodexUsage {
|
||||
input_tokens: 4,
|
||||
output_tokens: 9,
|
||||
})
|
||||
.expect("send first usage");
|
||||
event_tx
|
||||
.send(AppEvent::CodexUsage {
|
||||
input_tokens: 6,
|
||||
output_tokens: 1,
|
||||
})
|
||||
.expect("send second usage");
|
||||
|
||||
drain_workspace_events(&mut app).expect("drain events");
|
||||
|
||||
let workspace = app.workspace.as_ref().expect("workspace");
|
||||
assert_eq!(workspace.session_input_tokens, Some(10));
|
||||
assert_eq!(workspace.session_output_tokens, Some(10));
|
||||
}
|
||||
}
|
||||
56
src/app/runtime/mod.rs
Normal file
56
src/app/runtime/mod.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
mod events;
|
||||
mod usage;
|
||||
mod workspace;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::model::{SessionSource, SessionStream, TaskConfig};
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub(super) fn open_workspace_from_task_file(&mut self, task_path: PathBuf) -> Result<()> {
|
||||
workspace::open_workspace_from_task_file(self, task_path)
|
||||
}
|
||||
|
||||
pub(super) fn create_workspace_from_goal(&mut self, goal: String) -> Result<()> {
|
||||
workspace::create_workspace_from_goal(self, goal)
|
||||
}
|
||||
|
||||
pub(super) fn open_workspace(
|
||||
&mut self,
|
||||
config: TaskConfig,
|
||||
persist_task_path: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
workspace::open_workspace(self, config, persist_task_path)
|
||||
}
|
||||
|
||||
pub(super) fn refresh_picker(&mut self) -> Result<()> {
|
||||
workspace::refresh_picker(self)
|
||||
}
|
||||
|
||||
pub(super) fn drain_workspace_events(&mut self) -> Result<()> {
|
||||
events::drain_workspace_events(self)
|
||||
}
|
||||
|
||||
pub(super) fn maybe_refresh_usage(&mut self) -> Result<()> {
|
||||
usage::maybe_refresh_usage(self)
|
||||
}
|
||||
|
||||
pub(super) fn push_local_entry(
|
||||
&mut self,
|
||||
source: SessionSource,
|
||||
stream: SessionStream,
|
||||
title: &str,
|
||||
tag: Option<String>,
|
||||
body: &str,
|
||||
) {
|
||||
events::push_local_entry(self, source, stream, title, tag, body);
|
||||
}
|
||||
|
||||
pub(super) fn shutdown_runtime(&mut self) {
|
||||
workspace::shutdown_runtime(self);
|
||||
}
|
||||
}
|
||||
196
src/app/runtime/usage.rs
Normal file
196
src/app/runtime/usage.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::app::{App, USAGE_REFRESH_INTERVAL};
|
||||
use crate::model::{ControllerState, UsageSnapshot};
|
||||
use crate::storage::toon;
|
||||
|
||||
pub(super) fn maybe_refresh_usage(app: &mut App) -> Result<()> {
|
||||
let Some(workspace) = app.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;
|
||||
workspace.state.last_usage_primary_window = snapshot.primary.clone();
|
||||
workspace.state.last_usage_secondary_window = snapshot.secondary.clone();
|
||||
toon::write_state(&workspace.task_config.state_file, &workspace.state)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn state_has_usage(state: &ControllerState) -> bool {
|
||||
state.last_usage_primary_window.is_some()
|
||||
|| state.last_usage_secondary_window.is_some()
|
||||
|| state.last_usage_input_tokens.is_some()
|
||||
|| state.last_usage_output_tokens.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn usage_snapshot_from_state(state: &ControllerState) -> UsageSnapshot {
|
||||
if state_has_usage(state) {
|
||||
UsageSnapshot {
|
||||
input_tokens: state.last_usage_input_tokens,
|
||||
output_tokens: state.last_usage_output_tokens,
|
||||
primary: state.last_usage_primary_window.clone(),
|
||||
secondary: state.last_usage_secondary_window.clone(),
|
||||
refreshed_at: state.last_usage_refresh_at.clone(),
|
||||
available: true,
|
||||
note: None,
|
||||
}
|
||||
} else {
|
||||
UsageSnapshot::unavailable("usage unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::app::{ControlCommand, WorkspaceRuntime};
|
||||
use crate::cli::DEFAULT_TASK_CONFIG_PATH;
|
||||
use crate::model::{ControllerState, Plan, Screen, TaskConfig, UsageWindow};
|
||||
use crate::storage::toon;
|
||||
use crate::ui::scroll::VerticalScrollState;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sample_app(config: TaskConfig, state: ControllerState) -> App {
|
||||
let (_event_tx, event_rx) = mpsc::channel();
|
||||
let (control_tx, _control_rx) = mpsc::channel::<ControlCommand>();
|
||||
|
||||
App {
|
||||
screen: Screen::Workspace,
|
||||
picker_items: Vec::new(),
|
||||
picker_selected: 0,
|
||||
create_input: String::new(),
|
||||
create_error: None,
|
||||
default_task_path: PathBuf::from(DEFAULT_TASK_CONFIG_PATH),
|
||||
frame_tick: 0,
|
||||
workspace: Some(WorkspaceRuntime {
|
||||
task_config: config,
|
||||
goal_md: String::new(),
|
||||
standards_md: String::new(),
|
||||
plan: Plan::default(),
|
||||
state,
|
||||
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()
|
||||
- USAGE_REFRESH_INTERVAL
|
||||
- Duration::from_secs(1),
|
||||
session_follow_output: true,
|
||||
session_scrollbar: VerticalScrollState::new(false),
|
||||
session_rows: Vec::new(),
|
||||
session_view: None,
|
||||
session_view_area: ratatui::layout::Rect::default(),
|
||||
sidebar_follow_output: true,
|
||||
sidebar_scrollbar: VerticalScrollState::new(false),
|
||||
sidebar_view: None,
|
||||
sidebar_view_area: ratatui::layout::Rect::default(),
|
||||
session_selection: None,
|
||||
session_drag_active: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_refresh_usage_persists_cached_snapshot() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let controller_root = temp.path().join(".agent/controllers/usage");
|
||||
let config = TaskConfig {
|
||||
goal_file: controller_root.join("goal.md"),
|
||||
plan_file: controller_root.join("plan.toon"),
|
||||
state_file: controller_root.join("state.toon"),
|
||||
standards_file: controller_root.join("standards.md"),
|
||||
..TaskConfig::default_for("usage")
|
||||
};
|
||||
let state = ControllerState {
|
||||
last_usage_primary_window: Some(UsageWindow {
|
||||
used_percent: 4,
|
||||
resets_at: Some(300),
|
||||
window_duration_mins: Some(300),
|
||||
}),
|
||||
last_usage_secondary_window: Some(UsageWindow {
|
||||
used_percent: 71,
|
||||
resets_at: Some(700),
|
||||
window_duration_mins: Some(10_080),
|
||||
}),
|
||||
..ControllerState::default()
|
||||
};
|
||||
toon::write_state(&config.state_file, &state).expect("write state");
|
||||
|
||||
let mut app = sample_app(config.clone(), state);
|
||||
maybe_refresh_usage(&mut app).expect("refresh usage");
|
||||
|
||||
let workspace = app.workspace.as_ref().expect("workspace");
|
||||
assert!(workspace.usage_snapshot.available);
|
||||
assert_eq!(
|
||||
workspace
|
||||
.usage_snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
Some(4)
|
||||
);
|
||||
assert_eq!(
|
||||
workspace
|
||||
.usage_snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
Some(71)
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.usage_snapshot.note.as_deref(),
|
||||
Some("cached snapshot")
|
||||
);
|
||||
assert!(workspace.state.last_usage_refresh_at.is_some());
|
||||
|
||||
let persisted = toon::read_state(&config.state_file).expect("read state");
|
||||
assert_eq!(
|
||||
persisted
|
||||
.last_usage_primary_window
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
Some(4)
|
||||
);
|
||||
assert_eq!(
|
||||
persisted
|
||||
.last_usage_secondary_window
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
Some(71)
|
||||
);
|
||||
assert_eq!(
|
||||
persisted.last_usage_refresh_at,
|
||||
workspace.state.last_usage_refresh_at
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_snapshot_from_state_marks_missing_usage_unavailable() {
|
||||
let snapshot = usage_snapshot_from_state(&ControllerState::default());
|
||||
|
||||
assert!(!snapshot.available);
|
||||
assert_eq!(snapshot.input_tokens, None);
|
||||
assert_eq!(snapshot.output_tokens, None);
|
||||
assert_eq!(snapshot.primary, None);
|
||||
assert_eq!(snapshot.secondary, None);
|
||||
assert_eq!(snapshot.note.as_deref(), Some("usage unavailable"));
|
||||
}
|
||||
}
|
||||
261
src/app/runtime/workspace.rs
Normal file
261
src/app/runtime/workspace.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::app::{App, AppEvent, ControlCommand, WorkspaceRuntime};
|
||||
use crate::controller::engine;
|
||||
use crate::model::{Screen, SessionEntry, SessionSource, SessionStream, TaskConfig};
|
||||
use crate::repo;
|
||||
use crate::storage::toon;
|
||||
use crate::ui::scroll::VerticalScrollState;
|
||||
|
||||
use super::{events, usage};
|
||||
|
||||
pub(super) fn open_workspace_from_task_file(app: &mut App, task_path: PathBuf) -> Result<()> {
|
||||
let config = toon::read_task_config(&task_path)?;
|
||||
open_workspace(app, config, Some(task_path))
|
||||
}
|
||||
|
||||
pub(super) fn create_workspace_from_goal(app: &mut App, 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(&app.default_task_path, &controller_id)?;
|
||||
open_workspace(app, config, Some(app.default_task_path.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn open_workspace(
|
||||
app: &mut App,
|
||||
config: TaskConfig,
|
||||
persist_task_path: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
shutdown_runtime(app);
|
||||
|
||||
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 session_entries = initial_session_entries(&config, &state);
|
||||
let session_rows = events::rebuild_session_rows(&session_entries);
|
||||
let usage_snapshot = usage::usage_snapshot_from_state(&state);
|
||||
let (event_rx, control_tx) = spawn_runtime(&config);
|
||||
|
||||
app.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() - crate::app::USAGE_REFRESH_INTERVAL,
|
||||
session_follow_output: true,
|
||||
session_scrollbar: VerticalScrollState::new(false),
|
||||
session_rows,
|
||||
session_view: None,
|
||||
session_view_area: ratatui::layout::Rect::default(),
|
||||
sidebar_follow_output: true,
|
||||
sidebar_scrollbar: VerticalScrollState::new(false),
|
||||
sidebar_view: None,
|
||||
sidebar_view_area: ratatui::layout::Rect::default(),
|
||||
session_selection: None,
|
||||
session_drag_active: false,
|
||||
});
|
||||
app.screen = Screen::Workspace;
|
||||
refresh_picker(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn refresh_picker(app: &mut App) -> Result<()> {
|
||||
app.picker_items = toon::list_controller_summaries()?;
|
||||
if app.picker_selected > app.picker_items.len() {
|
||||
app.picker_selected = app.picker_items.len();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn shutdown_runtime(app: &mut App) {
|
||||
if let Some(workspace) = app.workspace.take() {
|
||||
let _ = workspace.control_tx.send(ControlCommand::Quit);
|
||||
}
|
||||
}
|
||||
|
||||
fn initial_session_entries(
|
||||
config: &TaskConfig,
|
||||
state: &crate::model::ControllerState,
|
||||
) -> Vec<SessionEntry> {
|
||||
let mut 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(),
|
||||
}];
|
||||
if let Some(reason) = state.phase_notice() {
|
||||
entries.push(SessionEntry {
|
||||
source: SessionSource::Warning,
|
||||
stream: SessionStream::Status,
|
||||
title: "Notice".to_string(),
|
||||
tag: Some(config.controller_id()),
|
||||
body: reason,
|
||||
run_id: repo::next_run_id(),
|
||||
});
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn spawn_runtime(config: &TaskConfig) -> (mpsc::Receiver<AppEvent>, mpsc::Sender<ControlCommand>) {
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
(event_rx, control_tx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::DEFAULT_TASK_CONFIG_PATH;
|
||||
use crate::model::{ControllerPhase, ControllerState, Plan, Screen, TaskConfig};
|
||||
use crate::storage::toon;
|
||||
use crate::test_support::CurrentDirGuard;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sample_app(default_task_path: PathBuf) -> App {
|
||||
App {
|
||||
screen: Screen::ControllerPicker,
|
||||
picker_items: Vec::new(),
|
||||
picker_selected: 3,
|
||||
create_input: String::new(),
|
||||
create_error: None,
|
||||
default_task_path,
|
||||
frame_tick: 0,
|
||||
workspace: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_workspace_from_task_file_hydrates_workspace_state() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let _cwd = CurrentDirGuard::enter(temp.path());
|
||||
|
||||
let task_path = temp.path().join(".agent/controller-loop/task.toon");
|
||||
let config = TaskConfig::default_for("runtime-open");
|
||||
toon::write_task_config(&task_path, &config).expect("write task config");
|
||||
toon::write_markdown(&config.goal_file, "# Goal\nShip runtime split").expect("write goal");
|
||||
toon::write_markdown(&config.standards_file, "# Standards\nKeep behavior stable")
|
||||
.expect("write standards");
|
||||
toon::write_plan(
|
||||
&config.plan_file,
|
||||
&Plan {
|
||||
goal_summary: "split runtime".to_string(),
|
||||
..Plan::default()
|
||||
},
|
||||
)
|
||||
.expect("write plan");
|
||||
toon::write_state(
|
||||
&config.state_file,
|
||||
&ControllerState {
|
||||
phase: ControllerPhase::Blocked,
|
||||
stop_reason: Some("Waiting on runtime refactor".to_string()),
|
||||
last_usage_refresh_at: Some("456".to_string()),
|
||||
last_usage_primary_window: Some(crate::model::UsageWindow {
|
||||
used_percent: 8,
|
||||
resets_at: Some(300),
|
||||
window_duration_mins: Some(300),
|
||||
}),
|
||||
last_usage_secondary_window: Some(crate::model::UsageWindow {
|
||||
used_percent: 13,
|
||||
resets_at: Some(700),
|
||||
window_duration_mins: Some(10_080),
|
||||
}),
|
||||
..ControllerState::default()
|
||||
},
|
||||
)
|
||||
.expect("write state");
|
||||
|
||||
let mut app = sample_app(PathBuf::from(DEFAULT_TASK_CONFIG_PATH));
|
||||
open_workspace_from_task_file(&mut app, task_path.clone()).expect("open workspace");
|
||||
|
||||
let workspace = app.workspace.as_ref().expect("workspace");
|
||||
assert!(matches!(app.screen, Screen::Workspace));
|
||||
assert_eq!(workspace.goal_md, "# Goal\nShip runtime split");
|
||||
assert_eq!(workspace.standards_md, "# Standards\nKeep behavior stable");
|
||||
assert_eq!(workspace.plan.goal_summary, "split runtime");
|
||||
assert!(workspace.state.started_at.is_some());
|
||||
assert_eq!(
|
||||
workspace
|
||||
.usage_snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
Some(8)
|
||||
);
|
||||
assert_eq!(
|
||||
workspace
|
||||
.usage_snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
Some(13)
|
||||
);
|
||||
assert_eq!(workspace.session_entries.len(), 2);
|
||||
assert!(workspace.session_entries[0]
|
||||
.body
|
||||
.contains("opened controller workspace"));
|
||||
assert_eq!(
|
||||
workspace.session_entries[1].body,
|
||||
"Waiting on runtime refactor"
|
||||
);
|
||||
assert_eq!(app.picker_selected, app.picker_items.len().min(3));
|
||||
|
||||
let persisted = toon::read_state(&config.state_file).expect("read state");
|
||||
assert_eq!(persisted.started_at, workspace.state.started_at);
|
||||
|
||||
shutdown_runtime(&mut app);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,158 @@
|
||||
use base64::Engine;
|
||||
use std::io::Write;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
|
||||
use crate::model::{group_session_entries, SessionCursor, SessionEntry};
|
||||
use crate::model::SessionCursor;
|
||||
use crate::ui;
|
||||
use crate::ui::scroll::{ScrollUnit, VerticalScrollHit};
|
||||
|
||||
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_scrollbar.set_position(0);
|
||||
workspace.session_follow_output = false;
|
||||
workspace.session_scrollbar.stop_arrow_hold();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn follow_session_output(&mut self) {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
workspace.session_follow_output = true;
|
||||
workspace.session_scrollbar.stop_arrow_hold();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_workspace_viewport(&mut self, terminal_height: usize) {
|
||||
pub(super) fn apply_sidebar_wheel(&mut self, direction: i8) {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
workspace.session_viewport_lines = terminal_height.saturating_sub(8);
|
||||
let current = if workspace.sidebar_follow_output {
|
||||
workspace.sidebar_scrollbar.scroll_range()
|
||||
} else {
|
||||
workspace.sidebar_scrollbar.position_lines
|
||||
};
|
||||
workspace.sidebar_scrollbar.position_lines = current;
|
||||
if workspace
|
||||
.sidebar_scrollbar
|
||||
.apply_wheel_tick(direction, Instant::now())
|
||||
{
|
||||
workspace.sidebar_follow_output = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_workspace_viewport(&mut self, terminal_area: ratatui::layout::Rect) {
|
||||
let layout = ui::workspace_layout(terminal_area);
|
||||
let sidebar_lines = ui::plan_board_lines(self);
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
if workspace.session_view.is_none() || workspace.session_view_area != layout.session {
|
||||
workspace.session_view = Some(ui::build_session_view(
|
||||
&layout,
|
||||
workspace.session_rows.as_slice(),
|
||||
));
|
||||
workspace.session_view_area = layout.session;
|
||||
}
|
||||
let Some(view) = workspace.session_view.as_ref() else {
|
||||
return;
|
||||
};
|
||||
workspace.session_scrollbar.set_content_viewport(
|
||||
view.metrics.total_lines,
|
||||
view.metrics.text_rect.height as usize,
|
||||
);
|
||||
if workspace.session_follow_output {
|
||||
workspace.session_scrollbar.follow_bottom();
|
||||
}
|
||||
|
||||
workspace.sidebar_view = Some(ui::build_sidebar_view(&layout, &sidebar_lines));
|
||||
workspace.sidebar_view_area = layout.sidebar;
|
||||
if let Some(sidebar_view) = workspace.sidebar_view.as_ref() {
|
||||
workspace.sidebar_scrollbar.set_content_viewport(
|
||||
sidebar_view.metrics.total_lines,
|
||||
sidebar_view.metrics.text_rect.height as usize,
|
||||
);
|
||||
if workspace.sidebar_follow_output {
|
||||
workspace.sidebar_scrollbar.follow_bottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mouse_over_session_text(
|
||||
&self,
|
||||
&mut 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))
|
||||
self.update_workspace_viewport(terminal_area);
|
||||
self.workspace_session_view()
|
||||
.map(|view| {
|
||||
view.metrics
|
||||
.text_rect
|
||||
.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn mouse_over_session_scrollbar(
|
||||
&mut self,
|
||||
mouse: MouseEvent,
|
||||
terminal_area: ratatui::layout::Rect,
|
||||
) -> bool {
|
||||
self.update_workspace_viewport(terminal_area);
|
||||
let Some(metrics) = self.workspace_session_view().map(|view| view.metrics) else {
|
||||
return false;
|
||||
};
|
||||
metrics.has_scrollbar
|
||||
&& metrics
|
||||
.scrollbar_rect
|
||||
.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
}
|
||||
|
||||
pub(super) fn mouse_over_sidebar_text(
|
||||
&mut self,
|
||||
mouse: MouseEvent,
|
||||
terminal_area: ratatui::layout::Rect,
|
||||
) -> bool {
|
||||
self.update_workspace_viewport(terminal_area);
|
||||
self.workspace_sidebar_view()
|
||||
.map(|view| {
|
||||
view.metrics
|
||||
.text_rect
|
||||
.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn mouse_over_sidebar_scrollbar(
|
||||
&mut self,
|
||||
mouse: MouseEvent,
|
||||
terminal_area: ratatui::layout::Rect,
|
||||
) -> bool {
|
||||
self.update_workspace_viewport(terminal_area);
|
||||
let Some(metrics) = self.workspace_sidebar_view().map(|view| view.metrics) else {
|
||||
return false;
|
||||
};
|
||||
metrics.has_scrollbar
|
||||
&& metrics
|
||||
.scrollbar_rect
|
||||
.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
}
|
||||
|
||||
pub(super) fn session_cursor_from_mouse(
|
||||
&self,
|
||||
&mut 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() {
|
||||
self.update_workspace_viewport(terminal_area);
|
||||
let view = self.workspace_session_view()?;
|
||||
if view.lines.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));
|
||||
let text_rect = view.metrics.text_rect;
|
||||
if text_rect.width == 0 || text_rect.height == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -106,44 +176,261 @@ impl App {
|
||||
);
|
||||
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)))?;
|
||||
let visual_line = view
|
||||
.lines
|
||||
.get(line_index.min(view.lines.len().saturating_sub(1)))?;
|
||||
|
||||
if !clamp {
|
||||
let (start, end) = row.selectable_range?;
|
||||
let (start, end) = visual_line.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,
|
||||
line: visual_line.row_index,
|
||||
column: visual_line.logical_start + rel_col,
|
||||
});
|
||||
}
|
||||
|
||||
let column = match row.selectable_range {
|
||||
let column = match visual_line.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))
|
||||
visual_line.logical_start + rel_col.clamp(start, end.saturating_sub(1))
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
Some(SessionCursor {
|
||||
line: line_index.min(rows.len().saturating_sub(1)),
|
||||
line: visual_line.row_index,
|
||||
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)
|
||||
let rows = self.workspace_session_rows()?;
|
||||
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 scroll_session_by_unit(&mut self, delta: f64, unit: ScrollUnit) {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
let current = if workspace.session_follow_output {
|
||||
workspace.session_scrollbar.scroll_range()
|
||||
} else {
|
||||
workspace.session_scrollbar.position_lines
|
||||
};
|
||||
workspace.session_scrollbar.position_lines = current;
|
||||
if workspace.session_scrollbar.scroll_by(delta, unit, 1) {
|
||||
workspace.session_follow_output = false;
|
||||
}
|
||||
workspace.session_scrollbar.stop_arrow_hold();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_session_wheel(&mut self, direction: i8) {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
let current = if workspace.session_follow_output {
|
||||
workspace.session_scrollbar.scroll_range()
|
||||
} else {
|
||||
workspace.session_scrollbar.position_lines
|
||||
};
|
||||
workspace.session_scrollbar.position_lines = current;
|
||||
if workspace
|
||||
.session_scrollbar
|
||||
.apply_wheel_tick(direction, Instant::now())
|
||||
{
|
||||
workspace.session_follow_output = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tick_session_scroll_repeat(&mut self) {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
let current = if workspace.session_follow_output {
|
||||
workspace.session_scrollbar.scroll_range()
|
||||
} else {
|
||||
workspace.session_scrollbar.position_lines
|
||||
};
|
||||
workspace.session_scrollbar.position_lines = current;
|
||||
if workspace.session_scrollbar.tick_arrow_hold(Instant::now()) {
|
||||
workspace.session_follow_output = false;
|
||||
}
|
||||
let current_sidebar = if workspace.sidebar_follow_output {
|
||||
workspace.sidebar_scrollbar.scroll_range()
|
||||
} else {
|
||||
workspace.sidebar_scrollbar.position_lines
|
||||
};
|
||||
workspace.sidebar_scrollbar.position_lines = current_sidebar;
|
||||
if workspace.sidebar_scrollbar.tick_arrow_hold(Instant::now()) {
|
||||
workspace.sidebar_follow_output = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_session_scroll_mouse(
|
||||
&mut self,
|
||||
mouse: MouseEvent,
|
||||
terminal_area: ratatui::layout::Rect,
|
||||
) -> bool {
|
||||
self.update_workspace_viewport(terminal_area);
|
||||
let Some(metrics) = self.workspace_session_view().map(|view| view.metrics) else {
|
||||
return false;
|
||||
};
|
||||
if !metrics.has_scrollbar {
|
||||
return false;
|
||||
}
|
||||
|
||||
let scrollbar_rect = metrics.scrollbar_rect;
|
||||
let point = ratatui::layout::Position::new(mouse.column, mouse.row);
|
||||
let over_scrollbar = scrollbar_rect.contains(point);
|
||||
let relative_row = mouse.row.saturating_sub(scrollbar_rect.y);
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) if over_scrollbar => {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
workspace.session_selection = None;
|
||||
workspace.session_drag_active = false;
|
||||
workspace.session_scrollbar.stop_arrow_hold();
|
||||
if workspace.session_follow_output {
|
||||
workspace.session_scrollbar.follow_bottom();
|
||||
}
|
||||
let hit = workspace.session_scrollbar.hit_test(
|
||||
workspace
|
||||
.session_scrollbar
|
||||
.metrics(scrollbar_rect.height as usize),
|
||||
relative_row,
|
||||
);
|
||||
if matches!(
|
||||
hit,
|
||||
VerticalScrollHit::ArrowStart | VerticalScrollHit::ArrowEnd
|
||||
) {
|
||||
let changed = workspace
|
||||
.session_scrollbar
|
||||
.start_arrow_hold(hit, Instant::now());
|
||||
if changed {
|
||||
workspace.session_follow_output = false;
|
||||
}
|
||||
} else if workspace.session_scrollbar.begin_drag(
|
||||
workspace
|
||||
.session_scrollbar
|
||||
.metrics(scrollbar_rect.height as usize),
|
||||
relative_row,
|
||||
) {
|
||||
workspace.session_follow_output = false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
if workspace.session_scrollbar.drag_state.is_some() {
|
||||
if workspace.session_scrollbar.drag_to(
|
||||
workspace
|
||||
.session_scrollbar
|
||||
.metrics(scrollbar_rect.height as usize),
|
||||
relative_row,
|
||||
) {
|
||||
workspace.session_follow_output = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
let consumed = workspace.session_scrollbar.drag_active()
|
||||
|| workspace.session_scrollbar.arrow_hold_active();
|
||||
workspace.session_scrollbar.stop_drag();
|
||||
workspace.session_scrollbar.stop_arrow_hold();
|
||||
return consumed;
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_sidebar_scroll_mouse(
|
||||
&mut self,
|
||||
mouse: MouseEvent,
|
||||
terminal_area: ratatui::layout::Rect,
|
||||
) -> bool {
|
||||
self.update_workspace_viewport(terminal_area);
|
||||
let Some(metrics) = self.workspace_sidebar_view().map(|view| view.metrics) else {
|
||||
return false;
|
||||
};
|
||||
if !metrics.has_scrollbar {
|
||||
return false;
|
||||
}
|
||||
|
||||
let scrollbar_rect = metrics.scrollbar_rect;
|
||||
let point = ratatui::layout::Position::new(mouse.column, mouse.row);
|
||||
let over_scrollbar = scrollbar_rect.contains(point);
|
||||
let relative_row = mouse.row.saturating_sub(scrollbar_rect.y);
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) if over_scrollbar => {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
workspace.sidebar_scrollbar.stop_arrow_hold();
|
||||
if workspace.sidebar_follow_output {
|
||||
workspace.sidebar_scrollbar.follow_bottom();
|
||||
}
|
||||
let hit = workspace.sidebar_scrollbar.hit_test(
|
||||
workspace
|
||||
.sidebar_scrollbar
|
||||
.metrics(scrollbar_rect.height as usize),
|
||||
relative_row,
|
||||
);
|
||||
if matches!(
|
||||
hit,
|
||||
VerticalScrollHit::ArrowStart | VerticalScrollHit::ArrowEnd
|
||||
) {
|
||||
let changed = workspace
|
||||
.sidebar_scrollbar
|
||||
.start_arrow_hold(hit, Instant::now());
|
||||
if changed {
|
||||
workspace.sidebar_follow_output = false;
|
||||
}
|
||||
} else if workspace.sidebar_scrollbar.begin_drag(
|
||||
workspace
|
||||
.sidebar_scrollbar
|
||||
.metrics(scrollbar_rect.height as usize),
|
||||
relative_row,
|
||||
) {
|
||||
workspace.sidebar_follow_output = false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
if workspace.sidebar_scrollbar.drag_state.is_some() {
|
||||
if workspace.sidebar_scrollbar.drag_to(
|
||||
workspace
|
||||
.sidebar_scrollbar
|
||||
.metrics(scrollbar_rect.height as usize),
|
||||
relative_row,
|
||||
) {
|
||||
workspace.sidebar_follow_output = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
let consumed = workspace.sidebar_scrollbar.drag_active()
|
||||
|| workspace.sidebar_scrollbar.arrow_hold_active();
|
||||
workspace.sidebar_scrollbar.stop_drag();
|
||||
workspace.sidebar_scrollbar.stop_arrow_hold();
|
||||
return consumed;
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
223
src/app/tests.rs
223
src/app/tests.rs
@@ -1,57 +1,72 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::Instant;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
use super::{App, WorkspaceRuntime};
|
||||
use super::{App, ControlCommand, WorkspaceRuntime};
|
||||
use crate::cli::DEFAULT_TASK_CONFIG_PATH;
|
||||
use crate::model::{
|
||||
ControllerPhase, ControllerState, Plan, Screen, SessionEntry, SessionSource, SessionStream,
|
||||
TaskConfig, UsageSnapshot,
|
||||
group_session_entries, ControllerPhase, ControllerState, Plan, Screen, SessionCursor,
|
||||
SessionEntry, SessionSelection, SessionSource, SessionStream, TaskConfig, UsageSnapshot,
|
||||
};
|
||||
use crate::ui::{self, scroll::VerticalScrollState};
|
||||
|
||||
fn sample_app() -> App {
|
||||
sample_app_with_control_rx().0
|
||||
}
|
||||
|
||||
fn sample_app_with_control_rx() -> (App, Receiver<ControlCommand>) {
|
||||
let (event_tx, event_rx) = mpsc::channel();
|
||||
let (control_tx, _control_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,
|
||||
}),
|
||||
}
|
||||
(
|
||||
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),
|
||||
frame_tick: 0,
|
||||
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_follow_output: true,
|
||||
session_scrollbar: VerticalScrollState::new(false),
|
||||
session_rows: Vec::new(),
|
||||
session_view: None,
|
||||
session_view_area: ratatui::layout::Rect::default(),
|
||||
sidebar_follow_output: true,
|
||||
sidebar_scrollbar: VerticalScrollState::new(false),
|
||||
sidebar_view: None,
|
||||
sidebar_view_area: ratatui::layout::Rect::default(),
|
||||
session_selection: None,
|
||||
session_drag_active: false,
|
||||
}),
|
||||
},
|
||||
control_rx,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -81,6 +96,68 @@ fn planning_mode_blocks_slash_commands() {
|
||||
assert!(last.body.contains("Slash commands"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_reports_current_workspace_progress() {
|
||||
let mut app = sample_app();
|
||||
app.screen = Screen::Workspace;
|
||||
if let Some(workspace) = app.workspace.as_mut() {
|
||||
workspace.state.phase = ControllerPhase::Executing;
|
||||
workspace.state.current_step_id = Some("s2".to_string());
|
||||
workspace.state.completed_steps = vec!["s1".to_string()];
|
||||
workspace.plan.steps = vec![
|
||||
crate::model::PlanStep {
|
||||
id: "s1".to_string(),
|
||||
..crate::model::PlanStep::default()
|
||||
},
|
||||
crate::model::PlanStep {
|
||||
id: "s2".to_string(),
|
||||
..crate::model::PlanStep::default()
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
app.dispatch_workspace_input("/status".to_string())
|
||||
.expect("dispatch");
|
||||
let last = app
|
||||
.workspace
|
||||
.as_ref()
|
||||
.and_then(|workspace| workspace.session_entries.last())
|
||||
.expect("status entry");
|
||||
assert_eq!(last.source, SessionSource::Controller);
|
||||
assert!(last.body.contains("phase=Executing"));
|
||||
assert!(last.body.contains("current_step=s2"));
|
||||
assert!(last.body.contains("completed=1/2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_clears_selection_and_sends_control_command() {
|
||||
let (mut app, control_rx) = sample_app_with_control_rx();
|
||||
app.screen = Screen::Workspace;
|
||||
if let Some(workspace) = app.workspace.as_mut() {
|
||||
workspace.session_follow_output = false;
|
||||
workspace.session_drag_active = true;
|
||||
workspace.session_selection = Some(SessionSelection {
|
||||
anchor: SessionCursor { line: 0, column: 1 },
|
||||
focus: SessionCursor { line: 1, column: 3 },
|
||||
});
|
||||
}
|
||||
|
||||
app.dispatch_workspace_input("Ship the picker".to_string())
|
||||
.expect("dispatch");
|
||||
|
||||
let workspace = app.workspace.as_ref().expect("workspace");
|
||||
let last = workspace.session_entries.last().expect("session entry");
|
||||
assert_eq!(last.source, SessionSource::User);
|
||||
assert_eq!(last.body, "Ship the picker");
|
||||
assert!(workspace.session_follow_output);
|
||||
assert!(workspace.session_selection.is_none());
|
||||
assert!(!workspace.session_drag_active);
|
||||
assert!(matches!(
|
||||
control_rx.try_recv().expect("submit command"),
|
||||
ControlCommand::Submit(message) if message == "Ship the picker"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_scroll_can_move_away_from_follow_mode() {
|
||||
let mut app = sample_app();
|
||||
@@ -104,14 +181,72 @@ fn workspace_scroll_can_move_away_from_follow_mode() {
|
||||
run_id: 2,
|
||||
},
|
||||
];
|
||||
workspace.session_viewport_lines = 3;
|
||||
workspace.session_scrollbar.set_content_viewport(11, 3);
|
||||
}
|
||||
|
||||
app.handle_workspace_key(KeyEvent::from(KeyCode::Up))
|
||||
.expect("scroll up");
|
||||
assert_eq!(app.workspace_session_scroll(), 8);
|
||||
assert_eq!(app.workspace_session_scroll(), 7);
|
||||
|
||||
app.handle_workspace_key(KeyEvent::from(KeyCode::Home))
|
||||
.expect("home");
|
||||
assert_eq!(app.workspace_session_scroll(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_mode_stays_pinned_to_bottom_when_content_grows() {
|
||||
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: (0..20)
|
||||
.map(|idx| format!("line {idx}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
run_id: 1,
|
||||
}];
|
||||
workspace.session_rows =
|
||||
ui::build_session_render_rows(&group_session_entries(&workspace.session_entries));
|
||||
workspace.session_view = None;
|
||||
workspace.session_follow_output = true;
|
||||
}
|
||||
|
||||
let area = ratatui::layout::Rect::new(0, 0, 100, 20);
|
||||
app.update_workspace_viewport(area);
|
||||
let before = app.workspace_session_scroll();
|
||||
|
||||
if let Some(workspace) = app.workspace.as_mut() {
|
||||
workspace.session_entries.push(SessionEntry {
|
||||
source: SessionSource::Planner,
|
||||
stream: SessionStream::Stdout,
|
||||
title: "Thought".to_string(),
|
||||
tag: None,
|
||||
body: (20..30)
|
||||
.map(|idx| format!("line {idx}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
run_id: 1,
|
||||
});
|
||||
workspace.session_rows =
|
||||
ui::build_session_render_rows(&group_session_entries(&workspace.session_entries));
|
||||
workspace.session_view = None;
|
||||
}
|
||||
|
||||
app.update_workspace_viewport(area);
|
||||
let after = app.workspace_session_scroll();
|
||||
let viewport_lines = app
|
||||
.workspace()
|
||||
.expect("workspace")
|
||||
.session_scrollbar
|
||||
.viewport_lines;
|
||||
assert!(after > before);
|
||||
assert_eq!(
|
||||
after,
|
||||
app.workspace_session_line_count()
|
||||
.saturating_sub(viewport_lines)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
|
||||
use crate::model::{
|
||||
ControllerPhase, SessionEntry, SessionSelection, SessionSource, SessionStream,
|
||||
};
|
||||
use crate::model::{ControllerPhase, SessionEntry, SessionSelection, SessionSource, SessionStream};
|
||||
use crate::repo;
|
||||
use crate::storage::toon;
|
||||
use crate::ui::scroll::ScrollUnit;
|
||||
|
||||
use super::{App, ControlCommand};
|
||||
|
||||
@@ -19,15 +18,47 @@ impl App {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(
|
||||
mouse.kind,
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
| MouseEventKind::Drag(MouseButton::Left)
|
||||
| MouseEventKind::Up(MouseButton::Left)
|
||||
) && self.handle_session_scroll_mouse(mouse, terminal_area)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(
|
||||
mouse.kind,
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
| MouseEventKind::Drag(MouseButton::Left)
|
||||
| MouseEventKind::Up(MouseButton::Left)
|
||||
) && self.handle_sidebar_scroll_mouse(mouse, terminal_area)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
if self.mouse_over_session_text(mouse, terminal_area) {
|
||||
self.scroll_session_by(-3);
|
||||
if self.mouse_over_session_text(mouse, terminal_area)
|
||||
|| self.mouse_over_session_scrollbar(mouse, terminal_area)
|
||||
{
|
||||
self.apply_session_wheel(-1);
|
||||
} else if self.mouse_over_sidebar_text(mouse, terminal_area)
|
||||
|| self.mouse_over_sidebar_scrollbar(mouse, terminal_area)
|
||||
{
|
||||
self.apply_sidebar_wheel(-1);
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
if self.mouse_over_session_text(mouse, terminal_area) {
|
||||
self.scroll_session_by(3);
|
||||
if self.mouse_over_session_text(mouse, terminal_area)
|
||||
|| self.mouse_over_session_scrollbar(mouse, terminal_area)
|
||||
{
|
||||
self.apply_session_wheel(1);
|
||||
} else if self.mouse_over_sidebar_text(mouse, terminal_area)
|
||||
|| self.mouse_over_sidebar_scrollbar(mouse, terminal_area)
|
||||
{
|
||||
self.apply_sidebar_wheel(1);
|
||||
}
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
@@ -89,19 +120,19 @@ impl App {
|
||||
match key.code {
|
||||
KeyCode::Esc => Ok(true),
|
||||
KeyCode::Up => {
|
||||
self.scroll_session_by(-1);
|
||||
self.scroll_session_by_unit(-0.2, ScrollUnit::Viewport);
|
||||
Ok(false)
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.scroll_session_by(1);
|
||||
self.scroll_session_by_unit(0.2, ScrollUnit::Viewport);
|
||||
Ok(false)
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.scroll_session_by(-10);
|
||||
self.scroll_session_by_unit(-0.5, ScrollUnit::Viewport);
|
||||
Ok(false)
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.scroll_session_by(10);
|
||||
self.scroll_session_by_unit(0.5, ScrollUnit::Viewport);
|
||||
Ok(false)
|
||||
}
|
||||
KeyCode::Home => {
|
||||
@@ -132,6 +163,14 @@ impl App {
|
||||
self.dispatch_workspace_input(input)?;
|
||||
Ok(false)
|
||||
}
|
||||
KeyCode::Char('k') if self.workspace_input().unwrap_or_default().is_empty() => {
|
||||
self.scroll_session_by_unit(-0.2, ScrollUnit::Viewport);
|
||||
Ok(false)
|
||||
}
|
||||
KeyCode::Char('j') if self.workspace_input().unwrap_or_default().is_empty() => {
|
||||
self.scroll_session_by_unit(0.2, ScrollUnit::Viewport);
|
||||
Ok(false)
|
||||
}
|
||||
KeyCode::Char(ch) => {
|
||||
if let Some(workspace) = self.workspace.as_mut() {
|
||||
workspace.input.push(ch);
|
||||
|
||||
@@ -6,7 +6,7 @@ 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,
|
||||
ControllerPhase, GoalStatus, SessionEntry, SessionSource, SessionStream, StepStatus, TaskConfig,
|
||||
};
|
||||
use crate::repo;
|
||||
use crate::storage::toon;
|
||||
@@ -30,32 +30,44 @@ pub fn runtime_loop(
|
||||
loop {
|
||||
let mut plan = toon::read_plan(&config.plan_file)?;
|
||||
let mut state = toon::read_state(&config.state_file)?;
|
||||
if recover_stale_execution_state(&config, &mut plan, &mut state, &event_tx)? {
|
||||
continue;
|
||||
}
|
||||
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(),
|
||||
});
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
|
||||
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)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
continue;
|
||||
}
|
||||
Ok(ControlCommand::Resume) => {
|
||||
crate::controller::state::resume(&mut state);
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
continue;
|
||||
}
|
||||
Ok(ControlCommand::Stop) => {
|
||||
state.phase = ControllerPhase::Blocked;
|
||||
state.goal_status = GoalStatus::Blocked;
|
||||
state.notes.push("Stopped by user".to_string());
|
||||
state.set_stop_reason("Stopped by user.");
|
||||
let reason = state
|
||||
.phase_notice()
|
||||
.unwrap_or_else(|| "Stopped by user.".to_string());
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
let _ = event_tx.send(AppEvent::Session(SessionEntry {
|
||||
source: SessionSource::Warning,
|
||||
stream: SessionStream::Status,
|
||||
title: "Notice".to_string(),
|
||||
tag: Some(config.controller_id()),
|
||||
body: reason,
|
||||
run_id: repo::next_run_id(),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
Ok(ControlCommand::Submit(text)) => {
|
||||
@@ -102,8 +114,10 @@ pub fn runtime_loop(
|
||||
|
||||
if goal_checker::is_done(&plan, &state)? {
|
||||
state.phase = ControllerPhase::Done;
|
||||
state.clear_stop_reason();
|
||||
state.goal_status = GoalStatus::Done;
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
let _ = event_tx.send(AppEvent::Session(SessionEntry {
|
||||
source: SessionSource::Controller,
|
||||
stream: SessionStream::Status,
|
||||
@@ -115,7 +129,8 @@ pub fn runtime_loop(
|
||||
continue;
|
||||
}
|
||||
|
||||
if state.replan_required || plan.has_no_actionable_steps() {
|
||||
let resumable_step = resumable_step(&plan, &state);
|
||||
if resumable_step.is_none() && (state.replan_required || plan.has_no_actionable_steps()) {
|
||||
let _ = event_tx.send(AppEvent::Session(SessionEntry {
|
||||
source: SessionSource::Planner,
|
||||
stream: SessionStream::Status,
|
||||
@@ -129,16 +144,29 @@ pub fn runtime_loop(
|
||||
state.replan_required = false;
|
||||
toon::write_plan(&config.plan_file, &plan)?;
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(step) = planner::next_step(&plan, &state) else {
|
||||
let Some(step) = resumable_step.or_else(|| 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(),
|
||||
state.set_stop_reason(
|
||||
"No actionable step remained and autonomous replan produced nothing.",
|
||||
);
|
||||
let reason = state
|
||||
.phase_notice()
|
||||
.unwrap_or_else(|| "Controller is blocked.".to_string());
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
let _ = event_tx.send(AppEvent::Session(SessionEntry {
|
||||
source: SessionSource::Warning,
|
||||
stream: SessionStream::Status,
|
||||
title: "Notice".to_string(),
|
||||
tag: Some(config.controller_id()),
|
||||
body: reason,
|
||||
run_id: repo::next_run_id(),
|
||||
}));
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -150,49 +178,68 @@ pub fn runtime_loop(
|
||||
body: format!("Executing {}", step.title),
|
||||
run_id: repo::next_run_id(),
|
||||
}));
|
||||
state.clear_stop_reason();
|
||||
state.replan_required = false;
|
||||
state
|
||||
.blocked_steps
|
||||
.retain(|blocked_step| blocked_step != &step.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)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &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 {}",
|
||||
state.set_stop_reason(format!(
|
||||
"Execution requested goal clarification while processing {}.",
|
||||
step.id
|
||||
));
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
continue;
|
||||
}
|
||||
|
||||
let verification = verifier::verify_step(&repo_root, &exec, &event_tx)?;
|
||||
if !verification.passed {
|
||||
plan.mark_blocked(&step.id);
|
||||
plan.append_step_note(&step.id, verification.summary.as_str());
|
||||
state.last_verification = Some(verification);
|
||||
state.blocked_steps.push(step.id.clone());
|
||||
state.replan_required = true;
|
||||
state.set_stop_reason(format!("Verification failed for {}.", step.id));
|
||||
toon::write_plan(&config.plan_file, &plan)?;
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
continue;
|
||||
}
|
||||
|
||||
let cleanup = verifier::verify_cleanup(&config, &step, &exec)?;
|
||||
if !cleanup.passed {
|
||||
plan.mark_todo(&step.id);
|
||||
plan.append_step_note(&step.id, cleanup.summary.as_str());
|
||||
state.last_cleanup_summary = Some(cleanup);
|
||||
state.set_stop_reason(format!(
|
||||
"Cleanup requirements were not satisfied for {}.",
|
||||
step.id
|
||||
));
|
||||
toon::write_plan(&config.plan_file, &plan)?;
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
continue;
|
||||
}
|
||||
|
||||
let tests = verifier::run_tests(&repo_root, &exec, &event_tx)?;
|
||||
if !tests.passed {
|
||||
plan.mark_todo(&step.id);
|
||||
plan.append_step_note(&step.id, tests.summary.as_str());
|
||||
state.last_full_test_summary = Some(tests);
|
||||
state.set_stop_reason(format!("Tests failed for {}.", step.id));
|
||||
toon::write_plan(&config.plan_file, &plan)?;
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -200,7 +247,215 @@ pub fn runtime_loop(
|
||||
state.complete_step(&step, verification, cleanup, tests);
|
||||
toon::write_plan(&config.plan_file, &plan)?;
|
||||
toon::write_state(&config.state_file, &state)?;
|
||||
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_snapshot(
|
||||
event_tx: &Sender<AppEvent>,
|
||||
goal_md: &str,
|
||||
standards_md: &str,
|
||||
plan: &crate::model::Plan,
|
||||
state: &crate::model::ControllerState,
|
||||
) {
|
||||
let _ = event_tx.send(AppEvent::Snapshot {
|
||||
goal_md: goal_md.to_string(),
|
||||
standards_md: standards_md.to_string(),
|
||||
plan: plan.clone(),
|
||||
state: state.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn resumable_step(
|
||||
plan: &crate::model::Plan,
|
||||
state: &crate::model::ControllerState,
|
||||
) -> Option<crate::model::PlanStep> {
|
||||
let current_step_id = state.current_step_id.as_deref()?;
|
||||
plan.steps
|
||||
.iter()
|
||||
.find(|step| {
|
||||
step.id == current_step_id
|
||||
&& matches!(
|
||||
step.status,
|
||||
StepStatus::Todo | StepStatus::Active | StepStatus::Blocked
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn recover_stale_execution_state(
|
||||
config: &TaskConfig,
|
||||
plan: &mut crate::model::Plan,
|
||||
state: &mut crate::model::ControllerState,
|
||||
event_tx: &Sender<AppEvent>,
|
||||
) -> Result<bool> {
|
||||
if state.current_step_id.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let active_steps = plan.active_step_ids();
|
||||
if active_steps.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
for step_id in &active_steps {
|
||||
plan.mark_todo(step_id);
|
||||
plan.append_step_note(
|
||||
step_id,
|
||||
"Controller recovered this step from stale active state and returned it to todo.",
|
||||
);
|
||||
}
|
||||
|
||||
state.phase = ControllerPhase::Executing;
|
||||
state.goal_status = GoalStatus::InProgress;
|
||||
state.clear_stop_reason();
|
||||
state.replan_required = false;
|
||||
let reason = format!(
|
||||
"Recovered stale active step state for {}. Reset {} to todo.",
|
||||
config.controller_id(),
|
||||
active_steps.join(", ")
|
||||
);
|
||||
state.notes.push(reason.clone());
|
||||
toon::write_plan(&config.plan_file, plan)?;
|
||||
toon::write_state(&config.state_file, state)?;
|
||||
let _ = event_tx.send(AppEvent::Session(SessionEntry {
|
||||
source: SessionSource::Controller,
|
||||
stream: SessionStream::Status,
|
||||
title: "Notice".to_string(),
|
||||
tag: Some(config.controller_id()),
|
||||
body: reason,
|
||||
run_id: repo::next_run_id(),
|
||||
}));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::mpsc;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
use crate::model::{ControllerState, Plan, PlanStep, StepStatus};
|
||||
use crate::storage::toon;
|
||||
|
||||
#[test]
|
||||
fn recovers_stale_active_step_without_current_step() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let mut config = TaskConfig::default_for("stale-active");
|
||||
let root = temp.path().join(".agent/controllers/stale-active");
|
||||
config.goal_file = root.join("goal.md");
|
||||
config.plan_file = root.join("plan.toon");
|
||||
config.state_file = root.join("state.toon");
|
||||
config.standards_file = root.join("standards.md");
|
||||
|
||||
let mut plan = Plan {
|
||||
version: 1,
|
||||
goal_summary: "goal".to_string(),
|
||||
steps: vec![PlanStep {
|
||||
id: "s1".to_string(),
|
||||
title: "Scope".to_string(),
|
||||
status: StepStatus::Active,
|
||||
attempts: 1,
|
||||
..PlanStep::default()
|
||||
}],
|
||||
};
|
||||
let mut state = ControllerState {
|
||||
phase: ControllerPhase::Blocked,
|
||||
goal_status: GoalStatus::Blocked,
|
||||
..ControllerState::default()
|
||||
};
|
||||
state
|
||||
.set_stop_reason("No actionable step remained and autonomous replan produced nothing.");
|
||||
|
||||
toon::ensure_controller_files(&config).expect("ensure files");
|
||||
let (event_tx, event_rx) = mpsc::channel();
|
||||
|
||||
let recovered = recover_stale_execution_state(&config, &mut plan, &mut state, &event_tx)
|
||||
.expect("recover");
|
||||
|
||||
assert!(recovered);
|
||||
assert!(matches!(plan.steps[0].status, StepStatus::Todo));
|
||||
assert!(plan.steps[0].notes.contains("stale active state"));
|
||||
assert!(matches!(state.phase, ControllerPhase::Executing));
|
||||
assert!(matches!(state.goal_status, GoalStatus::InProgress));
|
||||
assert!(state.stop_reason.is_none());
|
||||
let event = event_rx.recv().expect("notice event");
|
||||
match event {
|
||||
AppEvent::Session(entry) => assert!(entry.body.contains("Reset s1 to todo")),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumable_step_prefers_current_blocked_or_active_step() {
|
||||
let plan = Plan {
|
||||
version: 1,
|
||||
goal_summary: "goal".to_string(),
|
||||
steps: vec![
|
||||
PlanStep {
|
||||
id: "s1".to_string(),
|
||||
title: "Scope".to_string(),
|
||||
status: StepStatus::Blocked,
|
||||
..PlanStep::default()
|
||||
},
|
||||
PlanStep {
|
||||
id: "s2".to_string(),
|
||||
title: "Other".to_string(),
|
||||
status: StepStatus::Done,
|
||||
..PlanStep::default()
|
||||
},
|
||||
],
|
||||
};
|
||||
let state = ControllerState {
|
||||
phase: ControllerPhase::Blocked,
|
||||
goal_status: GoalStatus::Blocked,
|
||||
current_step_id: Some("s1".to_string()),
|
||||
replan_required: true,
|
||||
..ControllerState::default()
|
||||
};
|
||||
|
||||
let resumed = resumable_step(&plan, &state).expect("expected resumable step");
|
||||
assert_eq!(resumed.id, "s1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_snapshot_clones_current_plan_and_state() {
|
||||
let (event_tx, event_rx) = mpsc::channel();
|
||||
let plan = Plan {
|
||||
version: 1,
|
||||
goal_summary: "goal".to_string(),
|
||||
steps: vec![PlanStep {
|
||||
id: "s1".to_string(),
|
||||
title: "Scope".to_string(),
|
||||
status: StepStatus::Active,
|
||||
..PlanStep::default()
|
||||
}],
|
||||
};
|
||||
let state = ControllerState {
|
||||
phase: ControllerPhase::Executing,
|
||||
current_step_id: Some("s1".to_string()),
|
||||
..ControllerState::default()
|
||||
};
|
||||
|
||||
emit_snapshot(&event_tx, "goal body", "standards body", &plan, &state);
|
||||
|
||||
let event = event_rx.recv().expect("snapshot event");
|
||||
match event {
|
||||
AppEvent::Snapshot {
|
||||
goal_md,
|
||||
standards_md,
|
||||
plan,
|
||||
state,
|
||||
} => {
|
||||
assert_eq!(goal_md, "goal body");
|
||||
assert_eq!(standards_md, "standards body");
|
||||
assert!(matches!(plan.steps[0].status, StepStatus::Active));
|
||||
assert_eq!(state.current_step_id.as_deref(), Some("s1"));
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::app::AppEvent;
|
||||
use crate::model::{ExecutionResponse, Plan, PlanStep, SessionSource, TaskConfig};
|
||||
use crate::process;
|
||||
use crate::prompt;
|
||||
use crate::storage::toon;
|
||||
|
||||
pub fn implement(
|
||||
@@ -17,23 +18,27 @@ pub fn implement(
|
||||
) -> Result<ExecutionResponse> {
|
||||
let goal_md = toon::read_markdown(&config.goal_file)?;
|
||||
let standards_md = toon::read_markdown(&config.standards_file)?;
|
||||
let context = build_execution_context(plan, step);
|
||||
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"
|
||||
"Execution mode only. Do not ask the user questions.\n",
|
||||
"Complete the active step with the smallest correct change set.\n\n",
|
||||
"Efficiency rules:\n",
|
||||
"- Inspect only files likely relevant to this step.\n",
|
||||
"- Avoid repository-wide searches unless the focused path is exhausted.\n",
|
||||
"- Prefer targeted verification and targeted tests over broad full-suite runs.\n",
|
||||
"- Keep output terse. Use short summaries and short notes.\n",
|
||||
"- If the requested change is already present, return done.\n",
|
||||
"- If the goal is genuinely ambiguous, set needs_goal_clarification=true.\n\n",
|
||||
"Return empty arrays for verification_commands, test_commands, or notes when not needed.\n\n",
|
||||
"Goal summary:\n{goal}\n\n",
|
||||
"Standards summary:\n{standards}\n\n",
|
||||
"Execution context:\n{context}\n"
|
||||
),
|
||||
goal = goal_md,
|
||||
standards = standards_md,
|
||||
plan = serde_json::to_string_pretty(plan)?,
|
||||
step = serde_json::to_string_pretty(step)?,
|
||||
goal = prompt::compact_markdown(&goal_md, 8, 1200),
|
||||
standards = prompt::compact_markdown(&standards_md, 10, 1200),
|
||||
context = serde_json::to_string_pretty(&context)?,
|
||||
);
|
||||
|
||||
let schema = json!({
|
||||
@@ -59,3 +64,45 @@ pub fn implement(
|
||||
)?;
|
||||
Ok(serde_json::from_str(&raw)?)
|
||||
}
|
||||
|
||||
fn build_execution_context(plan: &Plan, step: &PlanStep) -> Value {
|
||||
let dependency_steps = step
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter_map(|dependency| {
|
||||
plan.steps
|
||||
.iter()
|
||||
.find(|candidate| &candidate.id == dependency)
|
||||
.map(|candidate| {
|
||||
json!({
|
||||
"id": candidate.id,
|
||||
"title": prompt::truncate_text(&candidate.title, 100),
|
||||
"status": candidate.status,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let next_steps = plan
|
||||
.steps
|
||||
.iter()
|
||||
.filter(|candidate| candidate.id != step.id)
|
||||
.filter(|candidate| !candidate.status.is_done())
|
||||
.take(3)
|
||||
.map(|candidate| {
|
||||
json!({
|
||||
"id": candidate.id,
|
||||
"title": prompt::truncate_text(&candidate.title, 100),
|
||||
"dependencies": prompt::compact_string_vec(&candidate.dependencies, 4, 60),
|
||||
"status": candidate.status,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
json!({
|
||||
"goal_summary": prompt::truncate_text(&plan.goal_summary, 200),
|
||||
"active_step": prompt::compact_step(step),
|
||||
"dependency_steps": dependency_steps,
|
||||
"next_pending_steps": next_steps,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app::AppEvent;
|
||||
use crate::model::{self, ControllerState, Plan, SessionSource, TaskConfig};
|
||||
use crate::model::{self, ControllerState, Plan, PlanDelta, SessionSource, TaskConfig};
|
||||
use crate::process;
|
||||
use crate::prompt;
|
||||
use crate::storage::toon;
|
||||
|
||||
pub fn refine_without_user_input(
|
||||
@@ -20,20 +22,27 @@ pub fn refine_without_user_input(
|
||||
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",
|
||||
"Use the goal, standards, current plan context, and state to produce a minimal plan delta.\n",
|
||||
"Keep the plan compact. Prefer the smallest set of changes that unblocks execution.\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",
|
||||
"Do not resend completed steps.\n",
|
||||
"Do not resend unchanged pending steps.\n",
|
||||
"Omit any field that does not need to change by leaving it null or empty.\n",
|
||||
"Use step_updates only for new or changed steps.\n",
|
||||
"Use remove_step_ids only for steps that should be deleted.\n",
|
||||
"Use pending_step_order only when pending-step order should change; otherwise return an empty array.\n",
|
||||
"Return only the delta object.\n\n",
|
||||
"Goal summary:\n{goal}\n\n",
|
||||
"Standards summary:\n{standards}\n\n",
|
||||
"Current plan context:\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)?,
|
||||
goal = prompt::compact_markdown(&goal_md, 8, 1200),
|
||||
standards = prompt::compact_markdown(&standards_md, 10, 1200),
|
||||
plan = serde_json::to_string_pretty(&build_replan_context(plan, state))?,
|
||||
state = serde_json::to_string_pretty(&build_replan_state_context(state))?,
|
||||
);
|
||||
let schema = model::plan_schema();
|
||||
let schema = model::plan_delta_schema();
|
||||
let raw = process::run_codex_with_schema(
|
||||
repo_root,
|
||||
&prompt,
|
||||
@@ -42,9 +51,107 @@ pub fn refine_without_user_input(
|
||||
SessionSource::Planner,
|
||||
Some(config.controller_id()),
|
||||
)?;
|
||||
Ok(serde_json::from_str(&raw)?)
|
||||
let delta: PlanDelta = serde_json::from_str(&raw)?;
|
||||
Ok(plan.apply_delta(delta))
|
||||
}
|
||||
|
||||
pub fn next_step(plan: &Plan, state: &ControllerState) -> Option<crate::model::PlanStep> {
|
||||
plan.next_actionable_step(&state.completed_steps)
|
||||
}
|
||||
|
||||
fn build_replan_context(plan: &Plan, state: &ControllerState) -> serde_json::Value {
|
||||
let remaining_steps = plan
|
||||
.steps
|
||||
.iter()
|
||||
.filter(|step| !step.status.is_done())
|
||||
.map(prompt::compact_step)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
json!({
|
||||
"goal_summary": plan.goal_summary,
|
||||
"current_step_id": state.current_step_id,
|
||||
"completed_step_ids": state.completed_steps,
|
||||
"blocked_step_ids": state.blocked_steps,
|
||||
"remaining_steps": remaining_steps,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_replan_state_context(state: &ControllerState) -> serde_json::Value {
|
||||
json!({
|
||||
"phase": state.phase,
|
||||
"goal_status": state.goal_status,
|
||||
"iteration": state.iteration,
|
||||
"replan_required": state.replan_required,
|
||||
"current_step_id": state.current_step_id,
|
||||
"completed_step_ids": state.completed_steps,
|
||||
"blocked_step_ids": state.blocked_steps,
|
||||
"latest_notice": state.latest_notice(),
|
||||
"last_verification_summary": state.last_verification.as_ref().map(|summary| summary.summary.clone()),
|
||||
"last_cleanup_summary": state.last_cleanup_summary.as_ref().map(|summary| summary.summary.clone()),
|
||||
"last_test_summary": state.last_full_test_summary.as_ref().map(|summary| summary.summary.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{PlanStep, StepStatus};
|
||||
|
||||
#[test]
|
||||
fn replan_context_omits_completed_steps() {
|
||||
let plan = Plan {
|
||||
version: 1,
|
||||
goal_summary: "goal".to_string(),
|
||||
steps: vec![
|
||||
PlanStep {
|
||||
id: "done-step".to_string(),
|
||||
title: "Done".to_string(),
|
||||
status: StepStatus::Done,
|
||||
..PlanStep::default()
|
||||
},
|
||||
PlanStep {
|
||||
id: "todo-step".to_string(),
|
||||
title: "Todo".to_string(),
|
||||
status: StepStatus::Todo,
|
||||
..PlanStep::default()
|
||||
},
|
||||
],
|
||||
};
|
||||
let state = ControllerState {
|
||||
current_step_id: Some("todo-step".to_string()),
|
||||
completed_steps: vec!["done-step".to_string()],
|
||||
..ControllerState::default()
|
||||
};
|
||||
|
||||
let context = build_replan_context(&plan, &state);
|
||||
|
||||
assert_eq!(context["goal_summary"], "goal");
|
||||
assert_eq!(context["current_step_id"], "todo-step");
|
||||
assert_eq!(context["completed_step_ids"], json!(["done-step"]));
|
||||
assert_eq!(
|
||||
context["remaining_steps"].as_array().expect("steps").len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(context["remaining_steps"][0]["id"], "todo-step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replan_state_context_omits_large_history_fields() {
|
||||
let state = ControllerState {
|
||||
iteration: 4,
|
||||
current_step_id: Some("todo-step".to_string()),
|
||||
completed_steps: vec!["done-step".to_string()],
|
||||
blocked_steps: vec!["blocked-step".to_string()],
|
||||
notes: vec!["long note".to_string()],
|
||||
..ControllerState::default()
|
||||
};
|
||||
|
||||
let context = build_replan_state_context(&state);
|
||||
|
||||
assert_eq!(context["iteration"], 4);
|
||||
assert_eq!(context["current_step_id"], "todo-step");
|
||||
assert_eq!(context["completed_step_ids"], json!(["done-step"]));
|
||||
assert!(context.get("history").is_none());
|
||||
assert!(context.get("notes").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ pub fn pause(state: &mut ControllerState) {
|
||||
|
||||
pub fn resume(state: &mut ControllerState) {
|
||||
state.phase = ControllerPhase::Executing;
|
||||
state.clear_stop_reason();
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ mod controller;
|
||||
mod error;
|
||||
mod model;
|
||||
mod planning;
|
||||
mod prompt;
|
||||
mod process;
|
||||
mod repo;
|
||||
mod storage;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod ui;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
774
src/model.rs
774
src/model.rs
@@ -1,774 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
301
src/model/controller.rs
Normal file
301
src/model/controller.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{PlanStep, UsageWindow};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Screen {
|
||||
ControllerPicker,
|
||||
CreateController,
|
||||
Workspace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ControllerPhase {
|
||||
#[default]
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GoalStatus {
|
||||
#[default]
|
||||
Unknown,
|
||||
InProgress,
|
||||
Done,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum StepStatus {
|
||||
#[default]
|
||||
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 => "[-]",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
#[serde(default)]
|
||||
pub stop_reason: Option<String>,
|
||||
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>,
|
||||
pub last_usage_primary_window: Option<UsageWindow>,
|
||||
pub last_usage_secondary_window: Option<UsageWindow>,
|
||||
}
|
||||
|
||||
impl Default for ControllerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
phase: ControllerPhase::Planning,
|
||||
stop_reason: None,
|
||||
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,
|
||||
last_usage_primary_window: None,
|
||||
last_usage_secondary_window: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ControllerState {
|
||||
pub fn set_stop_reason(&mut self, reason: impl Into<String>) {
|
||||
let reason = reason.into();
|
||||
self.stop_reason = Some(reason.clone());
|
||||
if self.notes.last() != Some(&reason) {
|
||||
self.notes.push(reason);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_stop_reason(&mut self) {
|
||||
self.stop_reason = None;
|
||||
}
|
||||
|
||||
pub fn latest_notice(&self) -> Option<String> {
|
||||
self.stop_reason
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
self.notes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|note| !note.trim().is_empty())
|
||||
.cloned()
|
||||
})
|
||||
.or_else(|| {
|
||||
(!self.blocked_steps.is_empty())
|
||||
.then(|| format!("Blocked step(s): {}", self.blocked_steps.join(", ")))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.last_verification
|
||||
.as_ref()
|
||||
.filter(|summary| !summary.passed)
|
||||
.map(|summary| summary.summary.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.last_cleanup_summary
|
||||
.as_ref()
|
||||
.filter(|summary| !summary.passed)
|
||||
.map(|summary| summary.summary.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.last_full_test_summary
|
||||
.as_ref()
|
||||
.filter(|summary| !summary.passed)
|
||||
.map(|summary| summary.summary.clone())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn phase_notice(&self) -> Option<String> {
|
||||
match self.phase {
|
||||
ControllerPhase::Blocked => Some(
|
||||
self.latest_notice()
|
||||
.unwrap_or_else(|| "Controller is blocked.".to_string()),
|
||||
),
|
||||
ControllerPhase::Done => Some(
|
||||
self.latest_notice()
|
||||
.unwrap_or_else(|| "Goal complete.".to_string()),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn complete_step(
|
||||
&mut self,
|
||||
step: &PlanStep,
|
||||
verification: VerificationSummary,
|
||||
cleanup: CleanupSummary,
|
||||
tests: TestSummary,
|
||||
) {
|
||||
self.current_step_id = None;
|
||||
self.clear_stop_reason();
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::PlanStep;
|
||||
|
||||
#[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 phase_notice_prefers_stop_reason_and_falls_back_to_blocked_steps() {
|
||||
let mut state = ControllerState {
|
||||
phase: ControllerPhase::Blocked,
|
||||
..ControllerState::default()
|
||||
};
|
||||
state.blocked_steps.push("s2".to_string());
|
||||
assert_eq!(state.phase_notice().as_deref(), Some("Blocked step(s): s2"));
|
||||
|
||||
state.set_stop_reason("Verification failed for s2.");
|
||||
assert_eq!(
|
||||
state.phase_notice().as_deref(),
|
||||
Some("Verification failed for s2.")
|
||||
);
|
||||
}
|
||||
}
|
||||
30
src/model/mod.rs
Normal file
30
src/model/mod.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
mod controller;
|
||||
mod plan;
|
||||
mod response;
|
||||
mod schema;
|
||||
mod session;
|
||||
mod usage;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use self::controller::{
|
||||
CleanupSummary, CommandSummary, ControllerPhase, ControllerState, GoalStatus, HistoryEvent,
|
||||
PlanningSessionMeta, PlanningTurn, Screen, StepStatus, TestSummary, VerificationSummary,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::plan::{
|
||||
CleanupRule, ContinueUntil, Plan, PlanDelta, PlanStep, TaskConfig, VerificationCheck,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::response::{ControllerSummary, ExecutionResponse, PlannerResponse};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::schema::{
|
||||
cleanup_rule_schema, plan_delta_schema, plan_schema, plan_step_schema,
|
||||
verification_check_schema,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::session::{
|
||||
group_session_entries, SessionCursor, SessionEntry, SessionGroup, SessionSelection,
|
||||
SessionSource, SessionStream,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::usage::{StatusSnapshot, UsageSnapshot, UsageWindow};
|
||||
446
src/model/plan.rs
Normal file
446
src/model/plan.rs
Normal file
@@ -0,0 +1,446 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::StepStatus;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ContinueUntil {
|
||||
#[default]
|
||||
FixedPoint,
|
||||
GreenRoot,
|
||||
ManualStop,
|
||||
}
|
||||
|
||||
#[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,
|
||||
#[serde(default)]
|
||||
pub notes: 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PlanDelta {
|
||||
#[serde(default)]
|
||||
pub goal_summary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub step_updates: Vec<PlanStep>,
|
||||
#[serde(default)]
|
||||
pub remove_step_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub pending_step_order: Vec<String>,
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
pub fn active_step_ids(&self) -> Vec<String> {
|
||||
self.steps
|
||||
.iter()
|
||||
.filter(|step| matches!(step.status, StepStatus::Active))
|
||||
.map(|step| step.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn append_step_note(&mut self, step_id: &str, note: impl AsRef<str>) {
|
||||
let note = note.as_ref().trim();
|
||||
if note.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(step) = self.steps.iter_mut().find(|step| step.id == step_id) {
|
||||
if step.notes.trim().is_empty() {
|
||||
step.notes = note.to_string();
|
||||
} else if !step.notes.contains(note) {
|
||||
step.notes = format!("{} {}", step.notes.trim(), note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_delta(&self, delta: PlanDelta) -> Self {
|
||||
let PlanDelta {
|
||||
goal_summary,
|
||||
step_updates,
|
||||
remove_step_ids,
|
||||
pending_step_order,
|
||||
} = delta;
|
||||
let remove_ids = remove_step_ids.into_iter().collect::<HashSet<_>>();
|
||||
let mut updates = step_updates
|
||||
.into_iter()
|
||||
.map(|step| (step.id.clone(), step))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut existing_steps = Vec::new();
|
||||
for step in &self.steps {
|
||||
if remove_ids.contains(&step.id) {
|
||||
continue;
|
||||
}
|
||||
existing_steps.push(updates.remove(&step.id).unwrap_or_else(|| step.clone()));
|
||||
}
|
||||
|
||||
let mut new_done_steps = Vec::new();
|
||||
let mut new_pending_steps = Vec::new();
|
||||
for (_, step) in updates {
|
||||
if step.status.is_done() {
|
||||
new_done_steps.push(step);
|
||||
} else {
|
||||
new_pending_steps.push(step);
|
||||
}
|
||||
}
|
||||
|
||||
let mut pending_steps = existing_steps
|
||||
.iter()
|
||||
.filter(|step| !step.status.is_done())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
pending_steps.extend(new_pending_steps);
|
||||
let pending_steps = reorder_pending_steps(pending_steps, &pending_step_order);
|
||||
|
||||
let mut ordered_steps =
|
||||
Vec::with_capacity(existing_steps.len() + new_done_steps.len() + pending_steps.len());
|
||||
let mut pending_iter = pending_steps.into_iter();
|
||||
for step in existing_steps {
|
||||
if step.status.is_done() {
|
||||
ordered_steps.push(step);
|
||||
} else if let Some(next_pending) = pending_iter.next() {
|
||||
ordered_steps.push(next_pending);
|
||||
}
|
||||
}
|
||||
ordered_steps.extend(new_done_steps);
|
||||
ordered_steps.extend(pending_iter);
|
||||
|
||||
Self {
|
||||
version: self.version,
|
||||
goal_summary: goal_summary.unwrap_or_else(|| self.goal_summary.clone()),
|
||||
steps: ordered_steps,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reorder_pending_steps(steps: Vec<PlanStep>, pending_step_order: &[String]) -> Vec<PlanStep> {
|
||||
if pending_step_order.is_empty() {
|
||||
return steps;
|
||||
}
|
||||
|
||||
let mut by_id = HashMap::new();
|
||||
let mut remaining_ids = Vec::new();
|
||||
for step in steps {
|
||||
remaining_ids.push(step.id.clone());
|
||||
by_id.insert(step.id.clone(), step);
|
||||
}
|
||||
let mut ordered = Vec::new();
|
||||
|
||||
for step_id in pending_step_order {
|
||||
if let Some(step) = by_id.remove(step_id) {
|
||||
ordered.push(step);
|
||||
}
|
||||
}
|
||||
|
||||
for step_id in remaining_ids {
|
||||
if let Some(step) = by_id.remove(&step_id) {
|
||||
ordered.push(step);
|
||||
}
|
||||
}
|
||||
ordered
|
||||
}
|
||||
|
||||
#[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 append_step_note_deduplicates_trimmed_text() {
|
||||
let mut plan = Plan {
|
||||
version: 1,
|
||||
goal_summary: "goal".to_string(),
|
||||
steps: vec![PlanStep {
|
||||
id: "a".to_string(),
|
||||
title: "A".to_string(),
|
||||
notes: "existing".to_string(),
|
||||
..PlanStep::default()
|
||||
}],
|
||||
};
|
||||
|
||||
plan.append_step_note("a", " new detail ");
|
||||
plan.append_step_note("a", "new detail");
|
||||
plan.append_step_note("a", " ");
|
||||
|
||||
assert_eq!(plan.steps[0].notes, "existing new detail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delta_updates_only_changed_steps_and_keeps_done_steps() {
|
||||
let plan = Plan {
|
||||
version: 1,
|
||||
goal_summary: "goal".to_string(),
|
||||
steps: vec![
|
||||
PlanStep {
|
||||
id: "done-step".to_string(),
|
||||
title: "Done".to_string(),
|
||||
status: StepStatus::Done,
|
||||
..PlanStep::default()
|
||||
},
|
||||
PlanStep {
|
||||
id: "keep-step".to_string(),
|
||||
title: "Keep".to_string(),
|
||||
status: StepStatus::Todo,
|
||||
..PlanStep::default()
|
||||
},
|
||||
PlanStep {
|
||||
id: "change-step".to_string(),
|
||||
title: "Old".to_string(),
|
||||
status: StepStatus::Todo,
|
||||
..PlanStep::default()
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let updated = plan.apply_delta(PlanDelta {
|
||||
goal_summary: Some("new goal".to_string()),
|
||||
step_updates: vec![PlanStep {
|
||||
id: "change-step".to_string(),
|
||||
title: "New".to_string(),
|
||||
status: StepStatus::Active,
|
||||
..PlanStep::default()
|
||||
}],
|
||||
remove_step_ids: Vec::new(),
|
||||
pending_step_order: vec!["change-step".to_string(), "keep-step".to_string()],
|
||||
});
|
||||
|
||||
assert_eq!(updated.goal_summary, "new goal");
|
||||
assert_eq!(updated.steps[0].id, "done-step");
|
||||
assert_eq!(updated.steps[1].id, "change-step");
|
||||
assert_eq!(updated.steps[1].title, "New");
|
||||
assert!(matches!(updated.steps[1].status, StepStatus::Active));
|
||||
assert_eq!(updated.steps[2].id, "keep-step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_delta_can_add_and_remove_pending_steps() {
|
||||
let plan = Plan {
|
||||
version: 1,
|
||||
goal_summary: "goal".to_string(),
|
||||
steps: vec![
|
||||
PlanStep {
|
||||
id: "old-step".to_string(),
|
||||
title: "Old".to_string(),
|
||||
status: StepStatus::Todo,
|
||||
..PlanStep::default()
|
||||
},
|
||||
PlanStep {
|
||||
id: "keep-step".to_string(),
|
||||
title: "Keep".to_string(),
|
||||
status: StepStatus::Blocked,
|
||||
..PlanStep::default()
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let updated = plan.apply_delta(PlanDelta {
|
||||
goal_summary: None,
|
||||
step_updates: vec![PlanStep {
|
||||
id: "new-step".to_string(),
|
||||
title: "New".to_string(),
|
||||
status: StepStatus::Todo,
|
||||
..PlanStep::default()
|
||||
}],
|
||||
remove_step_ids: vec!["old-step".to_string()],
|
||||
pending_step_order: vec!["keep-step".to_string(), "new-step".to_string()],
|
||||
});
|
||||
|
||||
assert_eq!(updated.goal_summary, "goal");
|
||||
assert_eq!(updated.steps.len(), 2);
|
||||
assert_eq!(updated.steps[0].id, "keep-step");
|
||||
assert_eq!(updated.steps[1].id, "new-step");
|
||||
}
|
||||
}
|
||||
34
src/model/response.rs
Normal file
34
src/model/response.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ControllerPhase, Plan};
|
||||
|
||||
#[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,
|
||||
}
|
||||
161
src/model/schema.rs
Normal file
161
src/model/schema.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn verification_check_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "commands"],
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cleanup_rule_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "description"],
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"description": { "type": "string" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plan_step_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"purpose",
|
||||
"notes",
|
||||
"inputs",
|
||||
"outputs",
|
||||
"dependencies",
|
||||
"verification",
|
||||
"cleanup_requirements",
|
||||
"status",
|
||||
"attempts"
|
||||
],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"purpose": { "type": "string" },
|
||||
"notes": { "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() -> 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plan_delta_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["goal_summary", "step_updates", "remove_step_ids", "pending_step_order"],
|
||||
"properties": {
|
||||
"goal_summary": { "type": ["string", "null"] },
|
||||
"step_updates": {
|
||||
"type": "array",
|
||||
"items": plan_step_schema()
|
||||
},
|
||||
"remove_step_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"pending_step_order": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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
|
||||
);
|
||||
assert_eq!(
|
||||
schema["properties"]["steps"]["items"]["properties"]["notes"]["type"],
|
||||
"string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_delta_schema_allows_partial_replans() {
|
||||
let schema = plan_delta_schema();
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
assert_eq!(
|
||||
schema["required"],
|
||||
json!([
|
||||
"goal_summary",
|
||||
"step_updates",
|
||||
"remove_step_ids",
|
||||
"pending_step_order"
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
schema["properties"]["goal_summary"]["type"],
|
||||
json!(["string", "null"])
|
||||
);
|
||||
assert_eq!(
|
||||
schema["properties"]["step_updates"]["items"]["additionalProperties"],
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
176
src/model/session.rs
Normal file
176
src/model/session.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_selection_orders_backwards_cursors() {
|
||||
let selection = SessionSelection {
|
||||
anchor: SessionCursor { line: 4, column: 8 },
|
||||
focus: SessionCursor { line: 2, column: 3 },
|
||||
};
|
||||
|
||||
let (start, end) = selection.ordered();
|
||||
assert_eq!(start.line, 2);
|
||||
assert_eq!(start.column, 3);
|
||||
assert_eq!(end.line, 4);
|
||||
assert_eq!(end.column, 8);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
77
src/model/usage.rs
Normal file
77
src/model/usage.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use super::ControllerPhase;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct UsageWindow {
|
||||
pub used_percent: u64,
|
||||
pub resets_at: Option<u64>,
|
||||
pub window_duration_mins: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageSnapshot {
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub primary: Option<UsageWindow>,
|
||||
pub secondary: Option<UsageWindow>,
|
||||
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,
|
||||
primary: None,
|
||||
secondary: 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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn usage_snapshot_default_marks_usage_unavailable() {
|
||||
let snapshot = UsageSnapshot::default();
|
||||
|
||||
assert!(!snapshot.available);
|
||||
assert_eq!(snapshot.note.as_deref(), Some("usage unavailable"));
|
||||
assert!(snapshot.refreshed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_snapshot_unavailable_preserves_custom_note() {
|
||||
let snapshot = UsageSnapshot::unavailable("codex usage unavailable");
|
||||
|
||||
assert_eq!(snapshot.note.as_deref(), Some("codex usage unavailable"));
|
||||
assert_eq!(snapshot.input_tokens, None);
|
||||
assert_eq!(snapshot.output_tokens, None);
|
||||
assert_eq!(snapshot.primary, None);
|
||||
assert_eq!(snapshot.secondary, None);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::model::{self, ControllerState, PlannerResponse, TaskConfig};
|
||||
use crate::prompt;
|
||||
|
||||
pub fn planning_schema() -> serde_json::Value {
|
||||
json!({
|
||||
@@ -23,19 +24,13 @@ pub fn planning_schema() -> serde_json::Value {
|
||||
}
|
||||
|
||||
pub fn build_planning_prompt(
|
||||
config: &TaskConfig,
|
||||
_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");
|
||||
let transcript = prompt::compact_turns(&state.planning_session.transcript, 6, 240);
|
||||
|
||||
format!(
|
||||
concat!(
|
||||
@@ -43,34 +38,27 @@ pub fn build_planning_prompt(
|
||||
"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",
|
||||
"- If you have enough information, return kind=final immediately.\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",
|
||||
"- Prefer 3 to 6 steps unless the goal truly needs more.\n",
|
||||
"- Keep each step.note to one short sentence.\n\n",
|
||||
"Current goal summary:\n{goal_md}\n\n",
|
||||
"Current standards summary:\n{standards_md}\n\n",
|
||||
"Recent transcript:\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"
|
||||
"- plan: structured machine-readable plan object with ordered steps, concise step notes, verification, cleanup requirements, and statuses.\n",
|
||||
"- Each step.notes field should explain the reason for the step or the current constraint in one short sentence.\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,
|
||||
goal_md = prompt::compact_markdown(goal_md, 10, 1400),
|
||||
standards_md = prompt::compact_markdown(standards_md, 10, 1200),
|
||||
transcript = transcript,
|
||||
latest = latest_user_input,
|
||||
latest = prompt::truncate_text(latest_user_input, 400),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn advance(
|
||||
let standards_md = toon::read_markdown(&config.standards_file)?;
|
||||
|
||||
state.phase = ControllerPhase::Planning;
|
||||
state.clear_stop_reason();
|
||||
state.planning_session.transcript.push(PlanningTurn {
|
||||
role: "user".to_string(),
|
||||
content: latest_user_input.to_string(),
|
||||
@@ -62,6 +63,7 @@ pub fn advance(
|
||||
toon::write_plan(&config.plan_file, &plan)?;
|
||||
|
||||
state.phase = ControllerPhase::Executing;
|
||||
state.clear_stop_reason();
|
||||
state.goal_revision += 1;
|
||||
state.goal_status = crate::model::GoalStatus::InProgress;
|
||||
state.replan_required = false;
|
||||
|
||||
604
src/process.rs
604
src/process.rs
@@ -1,604 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
196
src/process/codex.rs
Normal file
196
src/process/codex.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
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::{SessionEntry, SessionSource, SessionStream};
|
||||
use crate::repo;
|
||||
|
||||
use super::parser::{parse_codex_line, should_ignore_codex_stderr};
|
||||
|
||||
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()?;
|
||||
schema_file.write_all(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()?;
|
||||
schema_file.write_all(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")
|
||||
}
|
||||
8
src/process/mod.rs
Normal file
8
src/process/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
mod codex;
|
||||
mod parser;
|
||||
mod shell;
|
||||
mod usage;
|
||||
|
||||
pub use self::codex::{generate_controller_id, run_codex_with_schema};
|
||||
pub use self::shell::run_shell_commands;
|
||||
pub use self::usage::refresh_usage_snapshot;
|
||||
793
src/process/parser.rs
Normal file
793
src/process/parser.rs
Normal file
@@ -0,0 +1,793 @@
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ParsedCodexLine {
|
||||
pub(crate) title: String,
|
||||
pub(crate) display: String,
|
||||
pub(crate) input_tokens: u64,
|
||||
pub(crate) output_tokens: u64,
|
||||
}
|
||||
|
||||
pub(crate) 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_default(),
|
||||
)
|
||||
});
|
||||
|
||||
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 (title, display) = match title.as_str() {
|
||||
"Thinking" => render_thinking_event(item)?,
|
||||
"Command" => (title, render_command_event(event_type, item)?),
|
||||
"Patch" | "MCP" | "Plugin" => (title, render_tool_event(event_type, item)?),
|
||||
_ => (title, render_generic_item(item_type, item)?),
|
||||
};
|
||||
let display = display.trim().to_string();
|
||||
(!display.is_empty()).then_some((title, display))
|
||||
}
|
||||
|
||||
fn render_thinking_event(item: &Value) -> Option<(String, String)> {
|
||||
let text = item
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| first_text(item))?;
|
||||
|
||||
if let Some(summary) = summarize_plan_update(&text) {
|
||||
return Some(("Plan Update".to_string(), summary));
|
||||
}
|
||||
|
||||
Some(("Thinking".to_string(), text))
|
||||
}
|
||||
|
||||
fn summarize_plan_update(text: &str) -> Option<String> {
|
||||
let value = serde_json::from_str::<Value>(text).ok()?;
|
||||
if let Some(summary) = summarize_plan_delta_update(&value) {
|
||||
return Some(summary);
|
||||
}
|
||||
let plan = extract_plan_value(&value)?;
|
||||
let goal_summary = plan.get("goal_summary").and_then(Value::as_str)?.trim();
|
||||
let steps = plan.get("steps").and_then(Value::as_array)?;
|
||||
|
||||
let mut done = 0usize;
|
||||
let mut active = 0usize;
|
||||
let mut todo = 0usize;
|
||||
let mut blocked = 0usize;
|
||||
|
||||
for step in steps {
|
||||
match step
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"done" => done += 1,
|
||||
"active" => active += 1,
|
||||
"blocked" => blocked += 1,
|
||||
_ => todo += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let current = steps
|
||||
.iter()
|
||||
.find(|step| step.get("status").and_then(Value::as_str) == Some("active"))
|
||||
.or_else(|| {
|
||||
steps
|
||||
.iter()
|
||||
.find(|step| step.get("status").and_then(Value::as_str) == Some("todo"))
|
||||
})
|
||||
.or_else(|| {
|
||||
steps
|
||||
.iter()
|
||||
.find(|step| step.get("status").and_then(Value::as_str) == Some("blocked"))
|
||||
});
|
||||
|
||||
let current_line = current
|
||||
.map(summarize_plan_step)
|
||||
.unwrap_or_else(|| "current none".to_string());
|
||||
|
||||
Some(format!(
|
||||
"goal {}\nprogress {} done, {} active, {} todo, {} blocked\n{}",
|
||||
truncate_text(goal_summary, 120),
|
||||
done,
|
||||
active,
|
||||
todo,
|
||||
blocked,
|
||||
current_line
|
||||
))
|
||||
}
|
||||
|
||||
fn summarize_plan_delta_update(value: &Value) -> Option<String> {
|
||||
let delta = extract_plan_delta_value(value)?;
|
||||
let goal_summary = delta.get("goal_summary").and_then(Value::as_str);
|
||||
let step_updates = delta.get("step_updates").and_then(Value::as_array)?;
|
||||
let remove_step_ids = delta
|
||||
.get("remove_step_ids")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.len())
|
||||
.unwrap_or(0);
|
||||
let pending_step_order = delta
|
||||
.get("pending_step_order")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let headline = goal_summary
|
||||
.map(|summary| format!("goal {}", truncate_text(summary, 120)))
|
||||
.unwrap_or_else(|| "goal unchanged".to_string());
|
||||
let detail = format!(
|
||||
"updates {} steps, removes {}, reorders {}",
|
||||
step_updates.len(),
|
||||
remove_step_ids,
|
||||
pending_step_order
|
||||
);
|
||||
let focus = step_updates
|
||||
.iter()
|
||||
.find(|step| step.get("status").and_then(Value::as_str) == Some("active"))
|
||||
.or_else(|| step_updates.first())
|
||||
.map(summarize_plan_step)
|
||||
.unwrap_or_else(|| "current unchanged".to_string());
|
||||
|
||||
Some(format!("{headline}\n{detail}\n{focus}"))
|
||||
}
|
||||
|
||||
fn extract_plan_value(value: &Value) -> Option<&Value> {
|
||||
if value.get("version").is_some()
|
||||
&& value.get("goal_summary").is_some()
|
||||
&& value.get("steps").is_some()
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
value.get("plan").filter(|plan| {
|
||||
plan.get("version").is_some()
|
||||
&& plan.get("goal_summary").is_some()
|
||||
&& plan.get("steps").is_some()
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_plan_delta_value(value: &Value) -> Option<&Value> {
|
||||
if value.get("step_updates").is_some() && value.get("remove_step_ids").is_some() {
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
value
|
||||
.get("plan")
|
||||
.filter(|plan| plan.get("step_updates").is_some() && plan.get("remove_step_ids").is_some())
|
||||
}
|
||||
|
||||
fn summarize_plan_step(step: &Value) -> String {
|
||||
let status = step.get("status").and_then(Value::as_str).unwrap_or("todo");
|
||||
let label = match status {
|
||||
"active" => "active",
|
||||
"blocked" => "blocked",
|
||||
_ => "next",
|
||||
};
|
||||
let id = step.get("id").and_then(Value::as_str).unwrap_or("unknown");
|
||||
let title = step
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("untitled");
|
||||
format!("{label} {id}: {}", truncate_text(title, 96))
|
||||
}
|
||||
|
||||
fn truncate_text(text: &str, max_chars: usize) -> String {
|
||||
let text = text.trim();
|
||||
if max_chars == 0 || text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let suffix = "...";
|
||||
let keep = max_chars.saturating_sub(suffix.chars().count());
|
||||
let prefix = text.chars().take(keep).collect::<String>();
|
||||
format!("{prefix}{suffix}")
|
||||
}
|
||||
|
||||
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_preview(&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);
|
||||
|
||||
let output = summarize_diff_output(&output).unwrap_or(output);
|
||||
let output = truncate_command_output(&output, 6);
|
||||
|
||||
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_preview(&command)),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_command_preview(command: &str) -> String {
|
||||
let command = summarize_heredoc_command(command.trim());
|
||||
let command = truncate_command_preview_lines(&command, 6);
|
||||
let command = truncate_command_preview_chars(&command, 240);
|
||||
format!("$ {command}")
|
||||
}
|
||||
|
||||
fn summarize_heredoc_command(command: &str) -> String {
|
||||
let mut summarized = Vec::new();
|
||||
let mut lines = command.lines().peekable();
|
||||
|
||||
while let Some(line) = lines.next() {
|
||||
summarized.push(line.to_string());
|
||||
|
||||
let Some(delimiter) = heredoc_delimiter(line) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut omitted = 0usize;
|
||||
while let Some(next_line) = lines.peek().copied() {
|
||||
lines.next();
|
||||
if is_heredoc_terminator(next_line, &delimiter) {
|
||||
break;
|
||||
}
|
||||
omitted += 1;
|
||||
}
|
||||
|
||||
if omitted > 0 {
|
||||
summarized.push(format!("... {omitted} heredoc lines omitted"));
|
||||
}
|
||||
}
|
||||
|
||||
summarized.join("\n")
|
||||
}
|
||||
|
||||
fn heredoc_delimiter(line: &str) -> Option<String> {
|
||||
let marker = line.find("<<")?;
|
||||
let mut rest = &line[marker + 2..];
|
||||
if let Some(stripped) = rest.strip_prefix('-') {
|
||||
rest = stripped;
|
||||
}
|
||||
rest = rest.trim_start();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first = rest.chars().next()?;
|
||||
if matches!(first, '\'' | '"') {
|
||||
let quoted = &rest[first.len_utf8()..];
|
||||
let end = quoted.find(first)?;
|
||||
return Some(quoted[..end].to_string());
|
||||
}
|
||||
|
||||
let delimiter = rest
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.trim_matches(|ch: char| matches!(ch, ';' | ')' | '(' | '"' | '\''));
|
||||
(!delimiter.is_empty()).then(|| delimiter.to_string())
|
||||
}
|
||||
|
||||
fn is_heredoc_terminator(line: &str, delimiter: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
trimmed == delimiter
|
||||
|| trimmed
|
||||
.strip_prefix(delimiter)
|
||||
.map(|suffix| {
|
||||
suffix
|
||||
.chars()
|
||||
.all(|ch| matches!(ch, '"' | '\'' | ';' | ')' | '('))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn truncate_command_preview_lines(command: &str, max_lines: usize) -> String {
|
||||
if max_lines == 0 || command.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let lines = command.lines().collect::<Vec<_>>();
|
||||
if lines.len() <= max_lines {
|
||||
return command.to_string();
|
||||
}
|
||||
|
||||
let hidden = lines.len().saturating_sub(max_lines);
|
||||
format!(
|
||||
"{}\n... {hidden} more command lines omitted",
|
||||
lines[..max_lines].join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_command_preview_chars(command: &str, max_chars: usize) -> String {
|
||||
let char_count = command.chars().count();
|
||||
if max_chars == 0 || char_count <= max_chars {
|
||||
return command.to_string();
|
||||
}
|
||||
|
||||
let suffix = "... command text truncated";
|
||||
let keep = max_chars.saturating_sub(suffix.chars().count());
|
||||
if keep == 0 {
|
||||
return suffix.chars().take(max_chars).collect();
|
||||
}
|
||||
|
||||
let prefix = command.chars().take(keep).collect::<String>();
|
||||
format!("{prefix}{suffix}")
|
||||
}
|
||||
|
||||
fn truncate_command_output(output: &str, max_lines: usize) -> String {
|
||||
if max_lines == 0 || output.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let lines = output.lines().collect::<Vec<_>>();
|
||||
if lines.len() <= max_lines {
|
||||
return output.to_string();
|
||||
}
|
||||
|
||||
let hidden = lines.len().saturating_sub(max_lines);
|
||||
let visible = &lines[lines.len() - max_lines..];
|
||||
format!(
|
||||
"... {} earlier lines omitted\n{}",
|
||||
hidden,
|
||||
visible.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
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(summarize_diff_output(&output).unwrap_or(output));
|
||||
}
|
||||
|
||||
let status = string_field(item, &["status"]).unwrap_or_default();
|
||||
if !status.is_empty() && status != "completed" {
|
||||
return Some(status);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn render_generic_item(item_type: &str, item: &Value) -> Option<String> {
|
||||
if item_type.eq_ignore_ascii_case("file_change") {
|
||||
return string_field(item, &["path", "file_path", "filename", "file"]);
|
||||
}
|
||||
|
||||
first_text(item)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DiffFileSummary {
|
||||
path: String,
|
||||
added: usize,
|
||||
removed: usize,
|
||||
}
|
||||
|
||||
fn summarize_diff_output(text: &str) -> Option<String> {
|
||||
let summaries = summarize_unified_diff(text)?;
|
||||
Some(
|
||||
summaries
|
||||
.into_iter()
|
||||
.map(|summary| {
|
||||
format!(
|
||||
"edited {} +{} -{}",
|
||||
summary.path, summary.added, summary.removed
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn summarize_unified_diff(text: &str) -> Option<Vec<DiffFileSummary>> {
|
||||
let mut current: Option<DiffFileSummary> = None;
|
||||
let mut summaries = Vec::new();
|
||||
let mut saw_diff_marker = false;
|
||||
|
||||
for line in text.lines() {
|
||||
if let Some(path) = diff_file_path(line) {
|
||||
saw_diff_marker = true;
|
||||
if let Some(summary) = current.take() {
|
||||
summaries.push(summary);
|
||||
}
|
||||
current = Some(DiffFileSummary {
|
||||
path,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(summary) = current.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if line.starts_with("+++ ") || line.starts_with("--- ") || line.starts_with("@@") {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('+') {
|
||||
summary.added += 1;
|
||||
} else if line.starts_with('-') {
|
||||
summary.removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(summary) = current.take() {
|
||||
summaries.push(summary);
|
||||
}
|
||||
|
||||
if !saw_diff_marker || summaries.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(summaries)
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_file_path(line: &str) -> Option<String> {
|
||||
let rest = line.strip_prefix("diff --git ")?;
|
||||
let mut parts = rest.split_whitespace();
|
||||
let _old = parts.next()?;
|
||||
let new = parts.next()?;
|
||||
let path = new
|
||||
.strip_prefix("b/")
|
||||
.or_else(|| new.strip_prefix("a/"))
|
||||
.unwrap_or(new);
|
||||
Some(path.to_string())
|
||||
}
|
||||
|
||||
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) => (!is_internal_item_id(text)).then(|| 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) {
|
||||
if !is_internal_item_id(text) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
map.iter()
|
||||
.filter(|(key, _)| !matches!(key.as_str(), "id" | "type" | "status"))
|
||||
.find_map(|(_, value)| first_text(value))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_internal_item_id(text: &str) -> bool {
|
||||
let Some(suffix) = text.strip_prefix("item_") else {
|
||||
return false;
|
||||
};
|
||||
!suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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 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 summarizes_plan_json_thinking_as_plan_update() {
|
||||
let parsed = parse_codex_line(
|
||||
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"{\"version\":5,\"goal_summary\":\"Refactor remaining modules without changing behavior\",\"steps\":[{\"id\":\"guardrails\",\"title\":\"Add Refactor Guardrails\",\"status\":\"done\"},{\"id\":\"process-modules\",\"title\":\"Split Process Execution And Parsing\",\"status\":\"todo\"}]}"}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(parsed.title, "Plan Update");
|
||||
assert_eq!(
|
||||
parsed.display,
|
||||
"goal Refactor remaining modules without changing behavior\nprogress 1 done, 0 active, 1 todo, 0 blocked\nnext process-modules: Split Process Execution And Parsing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarizes_nested_plan_json_thinking_as_plan_update() {
|
||||
let parsed = parse_codex_line(
|
||||
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"{\"kind\":\"final\",\"plan\":{\"version\":5,\"goal_summary\":\"Ship planner cleanup\",\"steps\":[{\"id\":\"planner-cleanup\",\"title\":\"Trim Planner Output\",\"status\":\"active\"}]}}"}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(parsed.title, "Plan Update");
|
||||
assert_eq!(
|
||||
parsed.display,
|
||||
"goal Ship planner cleanup\nprogress 0 done, 1 active, 0 todo, 0 blocked\nactive planner-cleanup: Trim Planner Output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarizes_plan_delta_thinking_as_plan_update() {
|
||||
let parsed = parse_codex_line(
|
||||
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"{\"goal_summary\":null,\"step_updates\":[{\"id\":\"process-modules\",\"title\":\"Split Process Execution And Parsing\",\"status\":\"active\"}],\"remove_step_ids\":[\"old-step\"],\"pending_step_order\":[\"process-modules\"]}"}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(parsed.title, "Plan Update");
|
||||
assert_eq!(
|
||||
parsed.display,
|
||||
"goal unchanged\nupdates 1 steps, removes 1, reorders 1\nactive process-modules: Split Process Execution And Parsing"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 truncates_multiline_started_command_previews() {
|
||||
let parsed = parse_codex_line(
|
||||
r#"{"type":"item.started","item":{"id":"item_10","type":"command_execution","command":"/bin/zsh -lc \"line1\nline2\nline3\nline4\nline5\nline6\nline7\"","status":"in_progress"}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.display,
|
||||
"$ /bin/zsh -lc \"line1\nline2\nline3\nline4\nline5\nline6\n... 1 more command lines omitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_heredoc_bodies_in_command_previews() {
|
||||
let parsed = parse_codex_line(
|
||||
r#"{"type":"item.started","item":{"id":"item_11","type":"command_execution","command":"cat > /tmp/demo.rs <<'EOF'\nfirst\nsecond\nEOF\ncargo fmt","status":"in_progress"}}"#,
|
||||
);
|
||||
|
||||
assert!(parsed.display.contains("$ cat > /tmp/demo.rs <<'EOF'"));
|
||||
assert!(parsed.display.contains("... 2 heredoc lines omitted"));
|
||||
assert!(parsed.display.contains("cargo fmt"));
|
||||
assert!(!parsed.display.contains("first\nsecond"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_tool_events_and_nested_usage_tokens() {
|
||||
let started = parse_codex_line(
|
||||
r#"{"type":"item.started","item":{"id":"item_2","type":"mcp_tool_call","tool_name":"mcp__playwright__browser_snapshot"}}"#,
|
||||
);
|
||||
let completed = parse_codex_line(
|
||||
r#"{"type":"item.completed","usage":{"prompt_tokens":5},"item":{"id":"item_2","type":"patch_apply","tool_name":"apply_patch","output":"updated file","usage":{"completion_tokens":9}}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(started.title, "MCP");
|
||||
assert_eq!(started.display, "mcp__playwright__browser_snapshot");
|
||||
assert_eq!(completed.title, "Patch");
|
||||
assert_eq!(completed.display, "updated file");
|
||||
assert_eq!(completed.input_tokens, 5);
|
||||
assert_eq!(completed.output_tokens, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarizes_unified_diff_output_by_file() {
|
||||
let parsed = parse_codex_line(
|
||||
r#"{"type":"item.completed","item":{"id":"item_3","type":"command_execution","aggregated_output":"diff --git a/src/main.rs b/src/main.rs\nindex 1111111..2222222 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,4 @@\n keep\n-old\n+new\n+extra\ndiff --git a/src/lib.rs b/src/lib.rs\nindex 3333333..4444444 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -2,2 +2,1 @@\n-remove\n stay\n","exit_code":0,"status":"completed"}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.display,
|
||||
"edited src/main.rs +2 -1\nedited src/lib.rs +0 -1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_non_diff_command_output_unchanged() {
|
||||
let parsed = parse_codex_line(
|
||||
r#"{"type":"item.completed","item":{"id":"item_4","type":"command_execution","aggregated_output":"plain output\nsecond line","exit_code":0,"status":"completed"}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(parsed.display, "plain output\nsecond line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_command_output_to_latest_lines() {
|
||||
let parsed = parse_codex_line(
|
||||
r#"{"type":"item.completed","item":{"id":"item_5","type":"command_execution","aggregated_output":"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8","exit_code":0,"status":"completed"}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.display,
|
||||
"... 2 earlier lines omitted\nl3\nl4\nl5\nl6\nl7\nl8"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_internal_item_ids_in_generic_output() {
|
||||
let parsed = parse_codex_line(
|
||||
r#"{"type":"item.completed","item":{"id":"item_21","type":"file_change","status":"completed"}}"#,
|
||||
);
|
||||
|
||||
assert!(parsed.display.is_empty());
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
82
src/process/shell.rs
Normal file
82
src/process/shell.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::app::AppEvent;
|
||||
use crate::model::{CommandSummary, SessionEntry, SessionSource, SessionStream};
|
||||
use crate::repo;
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
210
src/process/usage.rs
Normal file
210
src/process/usage.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::model::{ControllerState, UsageSnapshot, UsageWindow};
|
||||
use crate::repo;
|
||||
|
||||
pub fn refresh_usage_snapshot(state: &ControllerState) -> UsageSnapshot {
|
||||
fetch_live_usage_snapshot().unwrap_or_else(|_| cached_usage_snapshot(state))
|
||||
}
|
||||
|
||||
fn cached_usage_snapshot(state: &ControllerState) -> UsageSnapshot {
|
||||
if state.last_usage_primary_window.is_some()
|
||||
|| state.last_usage_secondary_window.is_some()
|
||||
|| 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,
|
||||
primary: state.last_usage_primary_window.clone(),
|
||||
secondary: state.last_usage_secondary_window.clone(),
|
||||
refreshed_at: Some(repo::now_timestamp()),
|
||||
available: true,
|
||||
note: Some("cached snapshot".to_string()),
|
||||
}
|
||||
} else {
|
||||
UsageSnapshot::unavailable("codex usage unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_live_usage_snapshot() -> Result<UsageSnapshot> {
|
||||
let response = read_rate_limits()?;
|
||||
let snapshot = response
|
||||
.rate_limits_by_limit_id
|
||||
.as_ref()
|
||||
.and_then(|limits| limits.get("codex"))
|
||||
.cloned()
|
||||
.unwrap_or(response.rate_limits);
|
||||
|
||||
Ok(UsageSnapshot {
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
primary: snapshot.primary.map(Into::into),
|
||||
secondary: snapshot.secondary.map(Into::into),
|
||||
refreshed_at: Some(repo::now_timestamp()),
|
||||
available: true,
|
||||
note: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_rate_limits() -> Result<RateLimitResponse> {
|
||||
let mut child = Command::new("codex")
|
||||
.arg("app-server")
|
||||
.arg("--listen")
|
||||
.arg("stdio://")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn codex app-server")?;
|
||||
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.context("codex app-server stdin unavailable")?;
|
||||
for request in [
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"clientInfo": {
|
||||
"name": "codex-controller-loop",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "account/rateLimits/read",
|
||||
"params": serde_json::Value::Null,
|
||||
}),
|
||||
] {
|
||||
writeln!(stdin, "{request}")?;
|
||||
}
|
||||
stdin.flush()?;
|
||||
drop(stdin);
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("codex app-server stdout unavailable")?;
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
let mut rate_limits = None;
|
||||
let mut rpc_error = None;
|
||||
for line in reader.lines().map_while(std::result::Result::ok) {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match value.get("id").and_then(|id| id.as_u64()) {
|
||||
Some(2) => {
|
||||
if let Some(error) = value.get("error") {
|
||||
rpc_error = Some(error.to_string());
|
||||
} else if let Some(result) = value.get("result") {
|
||||
rate_limits = serde_json::from_value::<RateLimitResponse>(result.clone()).ok();
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
if let Some(rate_limits) = rate_limits {
|
||||
return Ok(rate_limits);
|
||||
}
|
||||
|
||||
if let Some(rpc_error) = rpc_error {
|
||||
return Err(anyhow!("account/rateLimits/read failed: {rpc_error}"));
|
||||
}
|
||||
|
||||
Err(anyhow!("account/rateLimits/read returned no result"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RateLimitResponse {
|
||||
rate_limits: RateLimitBucket,
|
||||
rate_limits_by_limit_id: Option<HashMap<String, RateLimitBucket>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RateLimitBucket {
|
||||
primary: Option<RateLimitWindowPayload>,
|
||||
secondary: Option<RateLimitWindowPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RateLimitWindowPayload {
|
||||
used_percent: u64,
|
||||
resets_at: Option<u64>,
|
||||
window_duration_mins: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<RateLimitWindowPayload> for UsageWindow {
|
||||
fn from(value: RateLimitWindowPayload) -> Self {
|
||||
Self {
|
||||
used_percent: value.used_percent,
|
||||
resets_at: value.resets_at,
|
||||
window_duration_mins: value.window_duration_mins,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn refresh_usage_snapshot_uses_cached_values_when_available() {
|
||||
let state = ControllerState {
|
||||
last_usage_primary_window: Some(UsageWindow {
|
||||
used_percent: 3,
|
||||
resets_at: Some(300),
|
||||
window_duration_mins: Some(300),
|
||||
}),
|
||||
last_usage_secondary_window: Some(UsageWindow {
|
||||
used_percent: 73,
|
||||
resets_at: Some(700),
|
||||
window_duration_mins: Some(10_080),
|
||||
}),
|
||||
..ControllerState::default()
|
||||
};
|
||||
|
||||
let snapshot = cached_usage_snapshot(&state);
|
||||
assert!(snapshot.available);
|
||||
assert_eq!(
|
||||
snapshot.primary.as_ref().and_then(|window| window.resets_at),
|
||||
Some(300)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| window.window_duration_mins),
|
||||
Some(Some(10_080))
|
||||
);
|
||||
assert!(snapshot.refreshed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_usage_snapshot_falls_back_when_usage_missing() {
|
||||
let snapshot = cached_usage_snapshot(&ControllerState::default());
|
||||
assert!(!snapshot.available);
|
||||
assert_eq!(snapshot.primary, None);
|
||||
assert_eq!(snapshot.secondary, None);
|
||||
}
|
||||
}
|
||||
154
src/prompt.rs
Normal file
154
src/prompt.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::model::{PlanStep, PlanningTurn};
|
||||
|
||||
pub fn compact_markdown(text: &str, max_lines: usize, max_chars: usize) -> String {
|
||||
compact_lines(
|
||||
text.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>(),
|
||||
max_lines,
|
||||
max_chars,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compact_turns(turns: &[PlanningTurn], keep_turns: usize, max_chars: usize) -> String {
|
||||
let omitted = turns.len().saturating_sub(keep_turns);
|
||||
let recent = turns
|
||||
.iter()
|
||||
.skip(omitted)
|
||||
.map(|turn| format!("{}: {}", turn.role, truncate_text(turn.content.trim(), max_chars)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if recent.is_empty() {
|
||||
return "(empty)".to_string();
|
||||
}
|
||||
|
||||
if omitted == 0 {
|
||||
recent.join("\n")
|
||||
} else {
|
||||
format!("... {} earlier turns omitted ...\n{}", omitted, recent.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compact_step(step: &PlanStep) -> Value {
|
||||
json!({
|
||||
"id": step.id,
|
||||
"title": truncate_text(&step.title, 120),
|
||||
"purpose": truncate_text(&step.purpose, 200),
|
||||
"notes": truncate_text(&step.notes, 200),
|
||||
"dependencies": step.dependencies,
|
||||
"inputs": compact_string_vec(&step.inputs, 4, 120),
|
||||
"outputs": compact_string_vec(&step.outputs, 4, 120),
|
||||
"verification": step.verification.iter().take(2).map(|check| {
|
||||
json!({
|
||||
"label": truncate_text(&check.label, 80),
|
||||
"commands": compact_string_vec(&check.commands, 2, 120),
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
"cleanup_requirements": step.cleanup_requirements.iter().take(2).map(|rule| {
|
||||
json!({
|
||||
"label": truncate_text(&rule.label, 80),
|
||||
"description": truncate_text(&rule.description, 140),
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
"status": step.status,
|
||||
"attempts": step.attempts,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compact_string_vec(items: &[String], max_items: usize, max_chars: usize) -> Vec<String> {
|
||||
let mut compacted = items
|
||||
.iter()
|
||||
.take(max_items)
|
||||
.map(|item| truncate_text(item.trim(), max_chars))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if items.len() > max_items {
|
||||
compacted.push(format!("... {} more omitted", items.len() - max_items));
|
||||
}
|
||||
|
||||
compacted
|
||||
}
|
||||
|
||||
pub fn truncate_text(text: &str, max_chars: usize) -> String {
|
||||
let text = text.trim();
|
||||
if text.is_empty() || text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let suffix = "...";
|
||||
let keep = max_chars.saturating_sub(suffix.len());
|
||||
format!("{}{}", text.chars().take(keep).collect::<String>(), suffix)
|
||||
}
|
||||
|
||||
fn compact_lines(lines: Vec<&str>, max_lines: usize, max_chars: usize) -> String {
|
||||
if lines.is_empty() {
|
||||
return "(empty)".to_string();
|
||||
}
|
||||
|
||||
let mut total_chars = 0usize;
|
||||
let mut kept = Vec::new();
|
||||
for line in lines.iter().take(max_lines) {
|
||||
let truncated = truncate_text(line, 160);
|
||||
let next = if kept.is_empty() {
|
||||
truncated.len()
|
||||
} else {
|
||||
truncated.len() + 1
|
||||
};
|
||||
if !kept.is_empty() && total_chars + next > max_chars {
|
||||
break;
|
||||
}
|
||||
total_chars += next;
|
||||
kept.push(truncated);
|
||||
}
|
||||
|
||||
if kept.is_empty() {
|
||||
return truncate_text(lines[0], max_chars.max(8));
|
||||
}
|
||||
|
||||
if lines.len() > kept.len() {
|
||||
kept.push(format!("... {} more lines omitted", lines.len() - kept.len()));
|
||||
}
|
||||
|
||||
kept.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compact_markdown_truncates_and_marks_omissions() {
|
||||
let text = "one\n\ntwo\nthree\nfour";
|
||||
let compacted = compact_markdown(text, 2, 40);
|
||||
assert!(compacted.contains("one"));
|
||||
assert!(compacted.contains("two"));
|
||||
assert!(compacted.contains("more lines omitted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_turns_keeps_recent_context_only() {
|
||||
let turns = vec![
|
||||
PlanningTurn {
|
||||
role: "user".to_string(),
|
||||
content: "first".to_string(),
|
||||
},
|
||||
PlanningTurn {
|
||||
role: "assistant".to_string(),
|
||||
content: "second".to_string(),
|
||||
},
|
||||
PlanningTurn {
|
||||
role: "user".to_string(),
|
||||
content: "third".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let compacted = compact_turns(&turns, 2, 20);
|
||||
assert!(compacted.contains("earlier turns omitted"));
|
||||
assert!(!compacted.contains("first"));
|
||||
assert!(compacted.contains("second"));
|
||||
assert!(compacted.contains("third"));
|
||||
}
|
||||
}
|
||||
34
src/repo.rs
34
src/repo.rs
@@ -49,6 +49,40 @@ pub fn format_age(timestamp: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_time_until(timestamp: Option<u64>) -> String {
|
||||
let Some(timestamp) = timestamp else {
|
||||
return "--".to_string();
|
||||
};
|
||||
|
||||
let now = now_timestamp_u64();
|
||||
if timestamp <= now {
|
||||
return "now".to_string();
|
||||
}
|
||||
|
||||
let seconds = timestamp - now;
|
||||
if seconds < 60 {
|
||||
format!("{seconds}s")
|
||||
} else if seconds < 3_600 {
|
||||
format!("{}m", seconds / 60)
|
||||
} else if seconds < 86_400 {
|
||||
let hours = seconds / 3_600;
|
||||
let minutes = (seconds % 3_600) / 60;
|
||||
if minutes == 0 {
|
||||
format!("{hours}h")
|
||||
} else {
|
||||
format!("{hours}h{minutes:02}m")
|
||||
}
|
||||
} else {
|
||||
let days = seconds / 86_400;
|
||||
let hours = (seconds % 86_400) / 3_600;
|
||||
if hours == 0 {
|
||||
format!("{days}d")
|
||||
} else {
|
||||
format!("{days}d{hours}h")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn absolute(path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
104
src/storage/toon/codec.rs
Normal file
104
src/storage/toon/codec.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
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, Plan, TaskConfig};
|
||||
use crate::repo;
|
||||
|
||||
pub fn read_task_config(path: &Path) -> Result<TaskConfig> {
|
||||
read_from_absolute_toon(path)
|
||||
}
|
||||
|
||||
pub fn write_task_config(path: &Path, config: &TaskConfig) -> Result<()> {
|
||||
write_to_absolute_toon(path, config)
|
||||
}
|
||||
|
||||
pub fn read_plan(path: &Path) -> Result<Plan> {
|
||||
read_from_absolute_toon(path)
|
||||
}
|
||||
|
||||
pub fn write_plan(path: &Path, plan: &Plan) -> Result<()> {
|
||||
write_to_absolute_toon(path, plan)
|
||||
}
|
||||
|
||||
pub fn read_state(path: &Path) -> Result<ControllerState> {
|
||||
read_from_absolute_toon(path)
|
||||
}
|
||||
|
||||
pub fn write_state(path: &Path, state: &ControllerState) -> Result<()> {
|
||||
write_to_absolute_toon(path, state)
|
||||
}
|
||||
|
||||
pub(crate) fn read_from_toon_path<T: DeserializeOwned>(path: &Path) -> Result<T> {
|
||||
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()))
|
||||
}
|
||||
|
||||
pub(crate) fn write_to_toon_path<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
let content =
|
||||
encode_default(value).with_context(|| format!("failed to encode {}", path.display()))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut temp = NamedTempFile::new_in(
|
||||
path.parent()
|
||||
.context("expected file to have a parent directory")?,
|
||||
)?;
|
||||
temp.write_all(content.as_bytes())?;
|
||||
temp.persist(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_from_absolute_toon<T: DeserializeOwned>(path: &Path) -> Result<T> {
|
||||
read_from_toon_path(&repo::absolute(path))
|
||||
}
|
||||
|
||||
fn write_to_absolute_toon<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
write_to_toon_path(&repo::absolute(path), value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::model::ControllerPhase;
|
||||
|
||||
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, ControllerPhase::Planning));
|
||||
}
|
||||
}
|
||||
205
src/storage/toon/controllers.rs
Normal file
205
src/storage/toon/controllers.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::model::{ControllerState, ControllerSummary, Plan, TaskConfig};
|
||||
use crate::repo;
|
||||
|
||||
use super::codec::{read_from_toon_path, write_task_config};
|
||||
use super::files::{ensure_controller_files, read_markdown_path, DEFAULT_GOAL};
|
||||
|
||||
pub(crate) const CONTROLLERS_ROOT: &str = ".agent/controllers";
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
pub(crate) 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_path(&root.join(controller_id).join("goal.md"))
|
||||
.unwrap_or_else(|_| DEFAULT_GOAL.to_string());
|
||||
let plan = read_from_toon_path::<Plan>(&root.join(controller_id).join("plan.toon"))
|
||||
.unwrap_or_default();
|
||||
let state =
|
||||
read_from_toon_path::<ControllerState>(&root.join(controller_id).join("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 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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use tempfile::tempdir;
|
||||
use toon_format::encode_default;
|
||||
|
||||
use crate::model::{ControllerPhase, ControllerState, Plan};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn controller_discovery_falls_back_to_plan_summary_when_goal_body_missing() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let root = temp.path().join(".agent/controllers");
|
||||
fs::create_dir_all(root.join("alpha")).expect("create alpha");
|
||||
fs::write(root.join("alpha/goal.md"), "# Goal\n\n").expect("write goal");
|
||||
fs::write(
|
||||
root.join("alpha/plan.toon"),
|
||||
encode_default(&Plan {
|
||||
version: 1,
|
||||
goal_summary: "Plan fallback summary".to_string(),
|
||||
steps: vec![],
|
||||
})
|
||||
.expect("encode plan"),
|
||||
)
|
||||
.expect("write plan");
|
||||
|
||||
let controllers = list_controller_summaries_in(&root).expect("list controllers");
|
||||
assert_eq!(controllers.len(), 1);
|
||||
assert_eq!(controllers[0].goal_summary, "Plan fallback summary");
|
||||
}
|
||||
|
||||
#[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 controller_discovery_sorts_by_last_updated_then_id() {
|
||||
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/state.toon"),
|
||||
encode_default(&ControllerState {
|
||||
started_at: Some("10".to_string()),
|
||||
..ControllerState::default()
|
||||
})
|
||||
.expect("encode alpha state"),
|
||||
)
|
||||
.expect("write alpha state");
|
||||
fs::write(
|
||||
root.join("beta/state.toon"),
|
||||
encode_default(&ControllerState {
|
||||
started_at: Some("20".to_string()),
|
||||
..ControllerState::default()
|
||||
})
|
||||
.expect("encode beta state"),
|
||||
)
|
||||
.expect("write beta state");
|
||||
|
||||
let controllers = list_controller_summaries_in(&root).expect("list controllers");
|
||||
assert_eq!(
|
||||
controllers
|
||||
.iter()
|
||||
.map(|item| item.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["beta", "alpha"]
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/storage/toon/files.rs
Normal file
61
src/storage/toon/files.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::model::{ControllerState, Plan, TaskConfig};
|
||||
use crate::repo;
|
||||
|
||||
use super::codec::{write_plan, write_state};
|
||||
|
||||
pub(crate) const DEFAULT_GOAL: &str = "# Goal\n\nDescribe the goal for this controller.\n";
|
||||
pub(crate) const DEFAULT_STANDARDS: &str =
|
||||
"# Standards\n\n- Keep code maintainable.\n- Avoid one-off hacks.\n- Leave tests green.\n";
|
||||
|
||||
pub fn ensure_controller_files(config: &TaskConfig) -> Result<()> {
|
||||
for path in [
|
||||
&config.goal_file,
|
||||
&config.plan_file,
|
||||
&config.state_file,
|
||||
&config.standards_file,
|
||||
] {
|
||||
let absolute = repo::absolute(path);
|
||||
if let Some(parent) = absolute.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
|
||||
if !repo::absolute(&config.goal_file).exists() {
|
||||
write_markdown(&config.goal_file, DEFAULT_GOAL)?;
|
||||
}
|
||||
if !repo::absolute(&config.standards_file).exists() {
|
||||
write_markdown(&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_markdown(path: &Path) -> Result<String> {
|
||||
read_markdown_path(&repo::absolute(path))
|
||||
}
|
||||
|
||||
pub fn write_markdown(path: &Path, content: &str) -> Result<()> {
|
||||
write_markdown_path(&repo::absolute(path), content)
|
||||
}
|
||||
|
||||
pub(crate) fn read_markdown_path(path: &Path) -> Result<String> {
|
||||
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn write_markdown_path(path: &Path, content: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
99
src/storage/toon/ids.rs
Normal file
99
src/storage/toon/ids.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use crate::repo;
|
||||
|
||||
use super::controllers::controller_exists;
|
||||
|
||||
const MAX_CONTROLLER_ID_LEN: usize = 48;
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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_id_with_suffix_trims_base_to_fit_length_limit() {
|
||||
let base = "abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz";
|
||||
let suffixed = controller_id_with_suffix(base, 42);
|
||||
assert!(suffixed.len() <= MAX_CONTROLLER_ID_LEN);
|
||||
assert!(suffixed.ends_with("-42"));
|
||||
}
|
||||
}
|
||||
95
src/storage/toon/mod.rs
Normal file
95
src/storage/toon/mod.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
mod codec;
|
||||
mod controllers;
|
||||
mod files;
|
||||
mod ids;
|
||||
|
||||
pub use self::codec::{
|
||||
read_plan, read_state, read_task_config, write_plan, write_state, write_task_config,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::controllers::{controller_exists, create_controller, list_controller_summaries};
|
||||
pub use self::files::{ensure_controller_files, read_markdown, write_markdown};
|
||||
#[allow(unused_imports)]
|
||||
pub use self::ids::{make_unique_controller_id, normalize_controller_id_candidate};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::model::{ControllerState, Plan, TaskConfig};
|
||||
use crate::test_support::CurrentDirGuard;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ensure_controller_files_writes_default_markdown_and_toon() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let _cwd = CurrentDirGuard::enter(temp.path());
|
||||
|
||||
let config = TaskConfig::default_for("guardrails");
|
||||
ensure_controller_files(&config).expect("ensure files");
|
||||
|
||||
let goal = read_markdown(&config.goal_file).expect("read goal");
|
||||
let standards = read_markdown(&config.standards_file).expect("read standards");
|
||||
let plan = read_plan(&config.plan_file).expect("read plan");
|
||||
let state = read_state(&config.state_file).expect("read state");
|
||||
|
||||
assert!(goal.starts_with("# Goal"));
|
||||
assert!(standards.starts_with("# Standards"));
|
||||
assert_eq!(plan.goal_summary, "No plan yet");
|
||||
assert_eq!(state.version, 1);
|
||||
}
|
||||
|
||||
#[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 _cwd = CurrentDirGuard::enter(temp.path());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_controller_files_preserves_existing_plan_and_state() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let _cwd = CurrentDirGuard::enter(temp.path());
|
||||
|
||||
let config = TaskConfig::default_for("existing");
|
||||
write_plan(
|
||||
&config.plan_file,
|
||||
&Plan {
|
||||
version: 1,
|
||||
goal_summary: "custom".to_string(),
|
||||
steps: vec![],
|
||||
},
|
||||
)
|
||||
.expect("write plan");
|
||||
write_state(
|
||||
&config.state_file,
|
||||
&ControllerState {
|
||||
version: 7,
|
||||
..ControllerState::default()
|
||||
},
|
||||
)
|
||||
.expect("write state");
|
||||
|
||||
ensure_controller_files(&config).expect("ensure files");
|
||||
|
||||
assert_eq!(
|
||||
read_plan(&config.plan_file)
|
||||
.expect("read plan")
|
||||
.goal_summary,
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(
|
||||
read_state(&config.state_file).expect("read state").version,
|
||||
7
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/test_support.rs
Normal file
29
src/test_support.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex, MutexGuard};
|
||||
|
||||
pub(crate) static CURRENT_DIR_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
pub(crate) struct CurrentDirGuard {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
previous: PathBuf,
|
||||
}
|
||||
|
||||
impl CurrentDirGuard {
|
||||
pub(crate) fn enter(path: &Path) -> Self {
|
||||
let lock = CURRENT_DIR_LOCK.lock().expect("lock cwd");
|
||||
let previous = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(path).expect("set cwd");
|
||||
Self {
|
||||
_lock: lock,
|
||||
previous,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CurrentDirGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.previous.exists() {
|
||||
let _ = std::env::set_current_dir(&self.previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
787
src/ui/mod.rs
787
src/ui/mod.rs
@@ -1,11 +1,10 @@
|
||||
pub(crate) mod scroll;
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
|
||||
ScrollbarState, Wrap,
|
||||
},
|
||||
widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
@@ -15,6 +14,8 @@ use crate::model::{
|
||||
};
|
||||
use crate::repo;
|
||||
|
||||
use self::scroll::{render_vertical_scrollbar, VerticalScrollStyles};
|
||||
|
||||
const BORDER: Color = Color::DarkGray;
|
||||
const BORDER_ACTIVE: Color = Color::Blue;
|
||||
const TEXT: Color = Color::Reset;
|
||||
@@ -42,6 +43,42 @@ pub(crate) struct SessionRenderRow {
|
||||
pub selectable_range: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct SessionViewMetrics {
|
||||
pub text_rect: Rect,
|
||||
pub scrollbar_rect: Rect,
|
||||
pub total_lines: usize,
|
||||
pub has_scrollbar: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SessionVisualLine {
|
||||
pub row_index: usize,
|
||||
pub logical_start: usize,
|
||||
pub logical_end: usize,
|
||||
pub selectable_range: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SessionView {
|
||||
pub metrics: SessionViewMetrics,
|
||||
pub lines: Vec<SessionVisualLine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct SidebarViewMetrics {
|
||||
pub text_rect: Rect,
|
||||
pub scrollbar_rect: Rect,
|
||||
pub total_lines: usize,
|
||||
pub has_scrollbar: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SidebarView {
|
||||
pub metrics: SidebarViewMetrics,
|
||||
pub lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl WorkspaceLayout {
|
||||
pub fn session_text_rect(&self, with_scrollbar: bool) -> Rect {
|
||||
let extra_right = if with_scrollbar { 1 } else { 0 };
|
||||
@@ -56,6 +93,76 @@ impl WorkspaceLayout {
|
||||
height: self.session.height.saturating_sub(2),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_scrollbar_rect(&self, with_scrollbar: bool) -> Rect {
|
||||
if !with_scrollbar {
|
||||
return Rect::default();
|
||||
}
|
||||
|
||||
Rect {
|
||||
x: self
|
||||
.session
|
||||
.x
|
||||
.saturating_add(self.session.width.saturating_sub(2)),
|
||||
y: self.session.y.saturating_add(1),
|
||||
width: 1,
|
||||
height: self.session.height.saturating_sub(2),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sidebar_text_rect(&self, with_scrollbar: bool) -> Rect {
|
||||
let extra_right = if with_scrollbar { 1 } else { 0 };
|
||||
Rect {
|
||||
x: self.sidebar.x.saturating_add(2),
|
||||
y: self.sidebar.y.saturating_add(1),
|
||||
width: self
|
||||
.sidebar
|
||||
.width
|
||||
.saturating_sub(4)
|
||||
.saturating_sub(extra_right),
|
||||
height: self.sidebar.height.saturating_sub(2),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sidebar_scrollbar_rect(&self, with_scrollbar: bool) -> Rect {
|
||||
if !with_scrollbar {
|
||||
return Rect::default();
|
||||
}
|
||||
|
||||
Rect {
|
||||
x: self
|
||||
.sidebar
|
||||
.x
|
||||
.saturating_add(self.sidebar.width.saturating_sub(2)),
|
||||
y: self.sidebar.y.saturating_add(1),
|
||||
width: 1,
|
||||
height: self.sidebar.height.saturating_sub(2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_session_view(
|
||||
layout: &WorkspaceLayout,
|
||||
rows: &[SessionRenderRow],
|
||||
) -> SessionView {
|
||||
let mut has_scrollbar = false;
|
||||
loop {
|
||||
let text_rect = layout.session_text_rect(has_scrollbar);
|
||||
let lines = build_session_visual_lines(rows, text_rect.width as usize);
|
||||
let next_has_scrollbar = lines.len() > text_rect.height as usize;
|
||||
if next_has_scrollbar == has_scrollbar {
|
||||
return SessionView {
|
||||
metrics: SessionViewMetrics {
|
||||
text_rect,
|
||||
scrollbar_rect: layout.session_scrollbar_rect(has_scrollbar),
|
||||
total_lines: lines.len(),
|
||||
has_scrollbar,
|
||||
},
|
||||
lines,
|
||||
};
|
||||
}
|
||||
has_scrollbar = next_has_scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_layout(area: Rect) -> WorkspaceLayout {
|
||||
@@ -81,6 +188,27 @@ pub(crate) fn workspace_layout(area: Rect) -> WorkspaceLayout {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_sidebar_view(layout: &WorkspaceLayout, lines: &[Line<'static>]) -> SidebarView {
|
||||
let mut has_scrollbar = false;
|
||||
loop {
|
||||
let text_rect = layout.sidebar_text_rect(has_scrollbar);
|
||||
let wrapped_lines = wrap_styled_lines(lines, text_rect.width as usize);
|
||||
let next_has_scrollbar = wrapped_lines.len() > text_rect.height as usize;
|
||||
if next_has_scrollbar == has_scrollbar {
|
||||
return SidebarView {
|
||||
metrics: SidebarViewMetrics {
|
||||
text_rect,
|
||||
scrollbar_rect: layout.sidebar_scrollbar_rect(has_scrollbar),
|
||||
total_lines: wrapped_lines.len(),
|
||||
has_scrollbar,
|
||||
},
|
||||
lines: wrapped_lines,
|
||||
};
|
||||
}
|
||||
has_scrollbar = next_has_scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App) {
|
||||
match app.screen {
|
||||
Screen::ControllerPicker => render_picker(frame, app),
|
||||
@@ -240,20 +368,50 @@ fn render_create_controller(frame: &mut Frame, app: &App) {
|
||||
|
||||
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())
|
||||
let visible_lines = app
|
||||
.workspace_session_view()
|
||||
.zip(app.workspace_session_rows())
|
||||
.map(|(session_view, session_rows)| {
|
||||
let visible_start = app.workspace_session_scroll();
|
||||
let visible_end = visible_start
|
||||
.saturating_add(session_view.metrics.text_rect.height as usize)
|
||||
.min(session_view.lines.len());
|
||||
render_visible_session_lines(
|
||||
session_rows,
|
||||
&session_view.lines[visible_start..visible_end],
|
||||
app.workspace_session_selection(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let session = Paragraph::new(visible_lines)
|
||||
.block(shell_block(" Session ", true))
|
||||
.style(Style::default().fg(TEXT))
|
||||
.scroll((app.workspace_session_scroll() as u16, 0))
|
||||
.wrap(Wrap { trim: false });
|
||||
.style(Style::default().fg(TEXT));
|
||||
|
||||
let sidebar = Paragraph::new(plan_board_lines(app))
|
||||
let sidebar_lines = app
|
||||
.workspace_sidebar_view()
|
||||
.map(|sidebar_view| {
|
||||
let visible_start = if let Some(workspace) = app.workspace() {
|
||||
let max_scroll = sidebar_view
|
||||
.metrics
|
||||
.total_lines
|
||||
.saturating_sub(workspace.sidebar_scrollbar.viewport_lines);
|
||||
if workspace.sidebar_follow_output {
|
||||
max_scroll
|
||||
} else {
|
||||
workspace.sidebar_scrollbar.position_lines.min(max_scroll)
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let visible_end = visible_start
|
||||
.saturating_add(sidebar_view.metrics.text_rect.height as usize)
|
||||
.min(sidebar_view.lines.len());
|
||||
sidebar_view.lines[visible_start..visible_end].to_vec()
|
||||
})
|
||||
.unwrap_or_else(|| plan_board_lines(app));
|
||||
let sidebar = Paragraph::new(sidebar_lines)
|
||||
.block(shell_block(" Plan Board ", false))
|
||||
.style(Style::default().fg(TEXT))
|
||||
.wrap(Wrap { trim: false });
|
||||
.style(Style::default().fg(TEXT));
|
||||
|
||||
let status = Paragraph::new(status_line(app))
|
||||
.block(shell_block(" Status ", true))
|
||||
@@ -283,22 +441,58 @@ fn render_workspace(frame: &mut Frame, app: &App) {
|
||||
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,
|
||||
);
|
||||
if let Some(session_view) = app
|
||||
.workspace_session_view()
|
||||
.filter(|view| view.metrics.has_scrollbar)
|
||||
{
|
||||
let scrollbar_lines = if let Some(workspace) = app.workspace() {
|
||||
let mut scrollbar_state = workspace.session_scrollbar.clone();
|
||||
scrollbar_state.position_lines = app.workspace_session_scroll();
|
||||
render_vertical_scrollbar(
|
||||
session_view.metrics.scrollbar_rect,
|
||||
&scrollbar_state,
|
||||
VerticalScrollStyles {
|
||||
track: Style::default().fg(BORDER),
|
||||
thumb: Style::default().fg(BORDER_ACTIVE),
|
||||
arrows: Style::default().fg(BORDER_ACTIVE),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let scrollbar = Paragraph::new(scrollbar_lines);
|
||||
frame.render_widget(scrollbar, session_view.metrics.scrollbar_rect);
|
||||
}
|
||||
|
||||
if let Some(sidebar_view) = app
|
||||
.workspace_sidebar_view()
|
||||
.filter(|view| view.metrics.has_scrollbar)
|
||||
{
|
||||
let scrollbar_lines = if let Some(workspace) = app.workspace() {
|
||||
let mut scrollbar_state = workspace.sidebar_scrollbar.clone();
|
||||
let max_scroll = sidebar_view
|
||||
.metrics
|
||||
.total_lines
|
||||
.saturating_sub(workspace.sidebar_scrollbar.viewport_lines);
|
||||
scrollbar_state.position_lines = if workspace.sidebar_follow_output {
|
||||
max_scroll
|
||||
} else {
|
||||
workspace.sidebar_scrollbar.position_lines.min(max_scroll)
|
||||
};
|
||||
render_vertical_scrollbar(
|
||||
sidebar_view.metrics.scrollbar_rect,
|
||||
&scrollbar_state,
|
||||
VerticalScrollStyles {
|
||||
track: Style::default().fg(BORDER),
|
||||
thumb: Style::default().fg(BORDER_ACTIVE),
|
||||
arrows: Style::default().fg(BORDER_ACTIVE),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let scrollbar = Paragraph::new(scrollbar_lines);
|
||||
frame.render_widget(scrollbar, sidebar_view.metrics.scrollbar_rect);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +529,13 @@ pub(crate) fn build_session_render_rows(groups: &[SessionGroup]) -> Vec<SessionR
|
||||
content_style: accent_style.add_modifier(Modifier::BOLD),
|
||||
});
|
||||
|
||||
for line in &group.lines {
|
||||
let visible_lines = if group.title == "Command" {
|
||||
truncate_group_lines(&group.lines, 6)
|
||||
} else {
|
||||
group.lines.clone()
|
||||
};
|
||||
|
||||
for line in &visible_lines {
|
||||
let text = format!("│ {line}");
|
||||
rows.push(SessionRenderRow {
|
||||
selectable_range: Some((2, text.chars().count())),
|
||||
@@ -361,61 +561,207 @@ pub(crate) fn build_session_render_rows(groups: &[SessionGroup]) -> Vec<SessionR
|
||||
rows
|
||||
}
|
||||
|
||||
fn render_session_lines(
|
||||
fn truncate_group_lines(lines: &[String], max_lines: usize) -> Vec<String> {
|
||||
if max_lines == 0 || lines.len() <= max_lines {
|
||||
return lines.to_vec();
|
||||
}
|
||||
|
||||
let hidden = lines.len().saturating_sub(max_lines);
|
||||
let mut visible = Vec::with_capacity(max_lines + 1);
|
||||
visible.push(format!("... {hidden} earlier lines omitted"));
|
||||
visible.extend(lines[lines.len() - max_lines..].iter().cloned());
|
||||
visible
|
||||
}
|
||||
|
||||
fn build_session_visual_lines(rows: &[SessionRenderRow], width: usize) -> Vec<SessionVisualLine> {
|
||||
if width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut visual_lines = Vec::new();
|
||||
for (row_index, row) in rows.iter().enumerate() {
|
||||
let total_chars = row.text.chars().count();
|
||||
if total_chars == 0 {
|
||||
visual_lines.push(SessionVisualLine {
|
||||
row_index,
|
||||
logical_start: 0,
|
||||
logical_end: 0,
|
||||
selectable_range: row.selectable_range.map(|_| (0, 0)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk_count = total_chars.div_ceil(width);
|
||||
for chunk_index in 0..chunk_count {
|
||||
let logical_start = chunk_index * width;
|
||||
let logical_end = (logical_start + width).min(total_chars);
|
||||
let selectable_range = row.selectable_range.and_then(|(start, end)| {
|
||||
let clipped_start = start.max(logical_start);
|
||||
let clipped_end = end.min(logical_end);
|
||||
(clipped_end > clipped_start)
|
||||
.then_some((clipped_start - logical_start, clipped_end - logical_start))
|
||||
});
|
||||
|
||||
visual_lines.push(SessionVisualLine {
|
||||
row_index,
|
||||
logical_start,
|
||||
logical_end,
|
||||
selectable_range,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
visual_lines
|
||||
}
|
||||
|
||||
fn render_visible_session_lines(
|
||||
rows: &[SessionRenderRow],
|
||||
visible_lines: &[SessionVisualLine],
|
||||
selection: Option<&SessionSelection>,
|
||||
) -> Vec<Line<'static>> {
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| render_session_row(row, index, selection))
|
||||
visible_lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let row = &rows[line.row_index];
|
||||
render_session_visual_line(
|
||||
row,
|
||||
line.row_index,
|
||||
selection,
|
||||
line.logical_start,
|
||||
line.logical_end,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_session_row(
|
||||
fn render_session_visual_line(
|
||||
row: &SessionRenderRow,
|
||||
row_index: usize,
|
||||
selection: Option<&SessionSelection>,
|
||||
logical_start: usize,
|
||||
logical_end: usize,
|
||||
) -> Line<'static> {
|
||||
let total_chars = row.text.chars().count();
|
||||
if logical_start >= total_chars || logical_start >= logical_end {
|
||||
return Line::from("");
|
||||
}
|
||||
|
||||
let content_start = row
|
||||
.selectable_range
|
||||
.map(|(start, _)| start)
|
||||
.unwrap_or(total_chars);
|
||||
let selected_range = selected_range_for_row(row, row_index, selection);
|
||||
let cells = row
|
||||
.text
|
||||
.chars()
|
||||
.enumerate()
|
||||
.skip(logical_start)
|
||||
.take(logical_end.saturating_sub(logical_start))
|
||||
.map(|(index, ch)| {
|
||||
let style = if index < content_start {
|
||||
row.border_style
|
||||
} else if selected_range
|
||||
.map(|(selected_start, selected_end)| {
|
||||
index >= selected_start && index < selected_end
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
selected_style(row.content_style)
|
||||
} else {
|
||||
row.content_style
|
||||
};
|
||||
(ch, style)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
line_from_cells(&cells)
|
||||
}
|
||||
|
||||
fn wrap_styled_lines(lines: &[Line<'static>], width: usize) -> Vec<Line<'static>> {
|
||||
if width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut wrapped = Vec::new();
|
||||
for line in lines {
|
||||
let cells = styled_line_cells(line);
|
||||
if cells.is_empty() {
|
||||
wrapped.push(Line::from(""));
|
||||
continue;
|
||||
}
|
||||
let chunk_count = cells.len().div_ceil(width);
|
||||
for chunk_index in 0..chunk_count {
|
||||
let start = chunk_index * width;
|
||||
let end = (start + width).min(cells.len());
|
||||
wrapped.push(line_from_cells(&cells[start..end]));
|
||||
}
|
||||
}
|
||||
wrapped
|
||||
}
|
||||
|
||||
fn styled_line_cells(line: &Line<'static>) -> Vec<(char, Style)> {
|
||||
line.spans
|
||||
.iter()
|
||||
.flat_map(|span| span.content.chars().map(move |ch| (ch, span.style)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn session_row_cells(
|
||||
row: &SessionRenderRow,
|
||||
row_index: usize,
|
||||
selection: Option<&SessionSelection>,
|
||||
) -> Vec<(char, Style)> {
|
||||
let total_chars = row.text.chars().count();
|
||||
let content_start = row
|
||||
.selectable_range
|
||||
.map(|(start, _)| start)
|
||||
.unwrap_or(total_chars);
|
||||
let selected_range = selected_range_for_row(row, row_index, selection);
|
||||
|
||||
row.text
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, ch)| {
|
||||
let style = if index < content_start {
|
||||
row.border_style
|
||||
} else if selected_range
|
||||
.map(|(selected_start, selected_end)| {
|
||||
index >= selected_start && index < selected_end
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
selected_style(row.content_style)
|
||||
} else {
|
||||
row.content_style
|
||||
};
|
||||
(ch, style)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn line_from_cells(cells: &[(char, Style)]) -> Line<'static> {
|
||||
let mut spans = Vec::new();
|
||||
if content_start > 0 {
|
||||
spans.push(Span::styled(
|
||||
slice_chars(&row.text, 0, content_start),
|
||||
row.border_style,
|
||||
));
|
||||
}
|
||||
let mut current_style = None;
|
||||
let mut current_text = String::new();
|
||||
|
||||
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,
|
||||
));
|
||||
for (ch, style) in cells {
|
||||
if current_style == Some(*style) {
|
||||
current_text.push(*ch);
|
||||
continue;
|
||||
}
|
||||
return Line::from(spans);
|
||||
};
|
||||
|
||||
if selected_start > content_start {
|
||||
spans.push(Span::styled(
|
||||
slice_chars(&row.text, content_start, selected_start),
|
||||
row.content_style,
|
||||
));
|
||||
if let Some(style) = current_style.take() {
|
||||
spans.push(Span::styled(std::mem::take(&mut current_text), style));
|
||||
}
|
||||
current_style = Some(*style);
|
||||
current_text.push(*ch);
|
||||
}
|
||||
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,
|
||||
));
|
||||
|
||||
if let Some(style) = current_style {
|
||||
spans.push(Span::styled(current_text, style));
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
@@ -478,7 +824,7 @@ fn slice_chars(text: &str, start: usize, end: usize) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plan_board_lines(app: &App) -> Vec<Line<'static>> {
|
||||
pub(crate) fn plan_board_lines(app: &App) -> Vec<Line<'static>> {
|
||||
let Some(workspace) = app.workspace() else {
|
||||
return vec![Line::from("No workspace loaded.")];
|
||||
};
|
||||
@@ -526,6 +872,27 @@ fn plan_board_lines(app: &App) -> Vec<Line<'static>> {
|
||||
Style::default().fg(TEXT),
|
||||
);
|
||||
|
||||
if let Some(notice) = workspace.state.phase_notice() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Notice",
|
||||
Style::default()
|
||||
.fg(
|
||||
if matches!(workspace.state.phase, ControllerPhase::Blocked) {
|
||||
RED
|
||||
} else {
|
||||
GOLD
|
||||
},
|
||||
)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(notice, Style::default().fg(TEXT)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Plan",
|
||||
Style::default().fg(GOLD).add_modifier(Modifier::BOLD),
|
||||
@@ -554,6 +921,10 @@ fn plan_board_lines(app: &App) -> Vec<Line<'static>> {
|
||||
),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(step_note(step), Style::default().fg(TEXT_DIM)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
@@ -606,41 +977,51 @@ fn status_line(app: &App) -> Line<'static> {
|
||||
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,
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!("{} ", heartbeat_symbol(app.heartbeat_frame())),
|
||||
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
status_kv("ctl", status.controller_id, CYAN),
|
||||
Span::raw(" "),
|
||||
status_kv("br", status.branch, TEXT),
|
||||
Span::raw(" "),
|
||||
status_kv("phase", status.phase.label(), CYAN),
|
||||
Span::raw(" "),
|
||||
status_kv("iter", status.iteration.to_string(), TEXT),
|
||||
status_kv("it", status.iteration.to_string(), TEXT),
|
||||
Span::raw(" "),
|
||||
status_kv("tok_in", token_label(status.session_input_tokens), GOLD),
|
||||
status_kv("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),
|
||||
])
|
||||
status_kv("out", token_label(status.session_output_tokens), GOLD),
|
||||
];
|
||||
|
||||
if let Some((label, value, color)) = usage_window_status("5h", status.usage.primary.as_ref()) {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(status_kv(label, value, color));
|
||||
}
|
||||
if let Some((label, value, color)) = usage_window_status("7d", status.usage.secondary.as_ref())
|
||||
{
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(status_kv(label, value, color));
|
||||
}
|
||||
if status.usage.primary.is_none() && status.usage.secondary.is_none() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(status_kv(
|
||||
"codex",
|
||||
status
|
||||
.usage
|
||||
.note
|
||||
.unwrap_or_else(|| "unavailable".to_string()),
|
||||
TEXT_DIM,
|
||||
));
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn heartbeat_symbol(frame: u64) -> &'static str {
|
||||
const FRAMES: [&str; 4] = ["|", "/", "-", "\\"];
|
||||
FRAMES[((frame / 4) as usize) % FRAMES.len()]
|
||||
}
|
||||
|
||||
fn token_label(value: Option<u64>) -> String {
|
||||
@@ -649,8 +1030,28 @@ fn token_label(value: Option<u64>) -> String {
|
||||
.unwrap_or_else(|| "--".to_string())
|
||||
}
|
||||
|
||||
fn source_style(source: SessionSource) -> Style {
|
||||
Style::default().fg(source_color(source))
|
||||
fn usage_window_status<'a>(
|
||||
label: &'a str,
|
||||
window: Option<&crate::model::UsageWindow>,
|
||||
) -> Option<(&'a str, String, Color)> {
|
||||
let window = window?;
|
||||
let left_percent = 100u64.saturating_sub(window.used_percent);
|
||||
let value = format!(
|
||||
"{}% left {}",
|
||||
left_percent,
|
||||
repo::format_time_until(window.resets_at)
|
||||
);
|
||||
Some((label, value, usage_window_color(left_percent)))
|
||||
}
|
||||
|
||||
fn usage_window_color(left_percent: u64) -> Color {
|
||||
if left_percent <= 10 {
|
||||
RED
|
||||
} else if left_percent <= 25 {
|
||||
GOLD
|
||||
} else {
|
||||
GREEN
|
||||
}
|
||||
}
|
||||
|
||||
fn source_color(source: SessionSource) -> Color {
|
||||
@@ -732,6 +1133,20 @@ fn push_sidebar_field(
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
fn step_note(step: &crate::model::PlanStep) -> String {
|
||||
if !step.notes.trim().is_empty() {
|
||||
step.notes.clone()
|
||||
} else if !step.purpose.trim().is_empty() {
|
||||
step.purpose.clone()
|
||||
} else if !step.outputs.is_empty() {
|
||||
format!("Expected output: {}", step.outputs.join(", "))
|
||||
} else if !step.dependencies.is_empty() {
|
||||
format!("Depends on: {}", step.dependencies.join(", "))
|
||||
} else {
|
||||
"No notes recorded.".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -761,9 +1176,10 @@ mod tests {
|
||||
|
||||
use crate::app::WorkspaceRuntime;
|
||||
use crate::model::{
|
||||
ControllerPhase, ControllerState, Plan, Screen, SessionEntry, SessionSource, SessionStream,
|
||||
TaskConfig, UsageSnapshot,
|
||||
group_session_entries, ControllerPhase, ControllerState, Plan, Screen, SessionEntry,
|
||||
SessionSource, SessionStream, TaskConfig, UsageSnapshot,
|
||||
};
|
||||
use crate::ui::scroll::VerticalScrollState;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -788,6 +1204,7 @@ mod tests {
|
||||
create_input: "Build the picker flow".to_string(),
|
||||
create_error: None,
|
||||
default_task_path: std::path::PathBuf::from(".agent/controller-loop/task.toon"),
|
||||
frame_tick: 0,
|
||||
workspace: Some(WorkspaceRuntime {
|
||||
task_config: TaskConfig::default_for("alpha"),
|
||||
goal_md: "# Goal\n\nShip it.\n".to_string(),
|
||||
@@ -817,19 +1234,52 @@ mod tests {
|
||||
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_scrollbar: VerticalScrollState::new(false),
|
||||
session_rows: build_session_render_rows(&group_session_entries(&[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,
|
||||
}])),
|
||||
session_view: None,
|
||||
session_view_area: Rect::default(),
|
||||
sidebar_follow_output: true,
|
||||
sidebar_scrollbar: VerticalScrollState::new(false),
|
||||
sidebar_view: None,
|
||||
sidebar_view_area: Rect::default(),
|
||||
session_selection: None,
|
||||
session_drag_active: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_to_text(app: &App) -> String {
|
||||
fn render_to_text(mut 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 area = Rect::new(0, 0, 100, 40);
|
||||
let layout = workspace_layout(area);
|
||||
let sidebar_lines = plan_board_lines(&app);
|
||||
if let Some(workspace) = app.workspace.as_mut() {
|
||||
let session_view = build_session_view(&layout, workspace.session_rows.as_slice());
|
||||
workspace.session_scrollbar.set_content_viewport(
|
||||
session_view.metrics.total_lines,
|
||||
session_view.metrics.text_rect.height as usize,
|
||||
);
|
||||
workspace.session_view = Some(session_view);
|
||||
workspace.session_view_area = layout.session;
|
||||
|
||||
let sidebar_view = build_sidebar_view(&layout, &sidebar_lines);
|
||||
workspace.sidebar_scrollbar.set_content_viewport(
|
||||
sidebar_view.metrics.total_lines,
|
||||
sidebar_view.metrics.text_rect.height as usize,
|
||||
);
|
||||
workspace.sidebar_view = Some(sidebar_view);
|
||||
workspace.sidebar_view_area = layout.sidebar;
|
||||
}
|
||||
terminal.draw(|frame| render(frame, &app)).expect("draw");
|
||||
let backend = terminal.backend();
|
||||
backend
|
||||
.buffer()
|
||||
@@ -843,7 +1293,7 @@ mod tests {
|
||||
#[test]
|
||||
fn renders_picker_screen() {
|
||||
let app = sample_app(Screen::ControllerPicker);
|
||||
let rendered = render_to_text(&app);
|
||||
let rendered = render_to_text(app);
|
||||
assert!(rendered.contains("Controller Picker"));
|
||||
assert!(rendered.contains("Create new controller"));
|
||||
}
|
||||
@@ -851,7 +1301,7 @@ mod tests {
|
||||
#[test]
|
||||
fn renders_create_screen() {
|
||||
let app = sample_app(Screen::CreateController);
|
||||
let rendered = render_to_text(&app);
|
||||
let rendered = render_to_text(app);
|
||||
assert!(rendered.contains("Create Controller"));
|
||||
assert!(rendered.contains("generated by GPT-5.4 mini"));
|
||||
}
|
||||
@@ -860,17 +1310,37 @@ mod tests {
|
||||
fn renders_workspace_screen() {
|
||||
let mut app = sample_app(Screen::Workspace);
|
||||
if let Some(workspace) = app.workspace.as_mut() {
|
||||
workspace.state.phase = ControllerPhase::Blocked;
|
||||
workspace
|
||||
.state
|
||||
.set_stop_reason("Verification failed for s1.");
|
||||
workspace.usage_snapshot.primary = Some(crate::model::UsageWindow {
|
||||
used_percent: 4,
|
||||
resets_at: Some(crate::repo::now_timestamp_u64() + 240 * 60),
|
||||
window_duration_mins: Some(300),
|
||||
});
|
||||
workspace.usage_snapshot.secondary = Some(crate::model::UsageWindow {
|
||||
used_percent: 73,
|
||||
resets_at: Some(crate::repo::now_timestamp_u64() + 3 * 86_400),
|
||||
window_duration_mins: Some(10_080),
|
||||
});
|
||||
workspace.plan.steps.push(crate::model::PlanStep {
|
||||
id: "s1".to_string(),
|
||||
title: "Design picker".to_string(),
|
||||
notes: "Confirm navigation model before implementation.".to_string(),
|
||||
status: crate::model::StepStatus::Todo,
|
||||
..crate::model::PlanStep::default()
|
||||
});
|
||||
}
|
||||
let rendered = render_to_text(&app);
|
||||
let rendered = render_to_text(app);
|
||||
assert!(rendered.contains("Session"));
|
||||
assert!(rendered.contains("Plan Board"));
|
||||
assert!(rendered.contains("controller=alpha"));
|
||||
assert!(rendered.contains("ctl=alpha"));
|
||||
assert!(rendered.contains("Notice"));
|
||||
assert!(rendered.contains("5h=96% left"));
|
||||
assert!(rendered.contains("7d=27% left"));
|
||||
assert!(rendered.contains("Verification failed"));
|
||||
assert!(rendered.contains("Confirm navigation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -881,6 +1351,83 @@ mod tests {
|
||||
assert_eq!(wrapped_line_count("abc\ndefghijk", 4), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_view_metrics_accounts_for_wrapped_rows() {
|
||||
let layout = WorkspaceLayout {
|
||||
session: Rect::new(0, 0, 10, 6),
|
||||
sidebar: Rect::default(),
|
||||
status: Rect::default(),
|
||||
composer: Rect::default(),
|
||||
};
|
||||
let rows = vec![
|
||||
SessionRenderRow {
|
||||
text: "123456".to_string(),
|
||||
border_style: Style::default(),
|
||||
content_style: Style::default(),
|
||||
selectable_range: Some((0, 6)),
|
||||
},
|
||||
SessionRenderRow {
|
||||
text: "abcdefg".to_string(),
|
||||
border_style: Style::default(),
|
||||
content_style: Style::default(),
|
||||
selectable_range: Some((0, 7)),
|
||||
},
|
||||
SessionRenderRow {
|
||||
text: "hijklmn".to_string(),
|
||||
border_style: Style::default(),
|
||||
content_style: Style::default(),
|
||||
selectable_range: Some((0, 7)),
|
||||
},
|
||||
];
|
||||
|
||||
let metrics = build_session_view(&layout, &rows).metrics;
|
||||
|
||||
assert!(metrics.has_scrollbar);
|
||||
assert_eq!(metrics.text_rect.width, 5);
|
||||
assert_eq!(metrics.total_lines, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_view_uses_full_text_width_when_scrollbar_is_not_needed() {
|
||||
let layout = WorkspaceLayout {
|
||||
session: Rect::new(0, 0, 20, 8),
|
||||
sidebar: Rect::default(),
|
||||
status: Rect::default(),
|
||||
composer: Rect::default(),
|
||||
};
|
||||
let rows = vec![SessionRenderRow {
|
||||
text: "short row".to_string(),
|
||||
border_style: Style::default(),
|
||||
content_style: Style::default(),
|
||||
selectable_range: Some((0, 9)),
|
||||
}];
|
||||
|
||||
let metrics = build_session_view(&layout, &rows).metrics;
|
||||
assert!(!metrics.has_scrollbar);
|
||||
assert_eq!(metrics.text_rect.width, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_row_cells_highlight_only_selected_content() {
|
||||
let row = SessionRenderRow {
|
||||
text: "│ plain text".to_string(),
|
||||
border_style: Style::default().fg(RED),
|
||||
content_style: Style::default().fg(TEXT),
|
||||
selectable_range: Some((2, 12)),
|
||||
};
|
||||
let selection = SessionSelection {
|
||||
anchor: crate::model::SessionCursor { line: 0, column: 0 },
|
||||
focus: crate::model::SessionCursor { line: 0, column: 6 },
|
||||
};
|
||||
|
||||
let cells = session_row_cells(&row, 0, Some(&selection));
|
||||
assert_eq!(cells[0].1, Style::default().fg(RED));
|
||||
assert_eq!(cells[1].1, Style::default().fg(RED));
|
||||
assert_eq!(cells[2].1, selected_style(Style::default().fg(TEXT)));
|
||||
assert_eq!(cells[6].1, selected_style(Style::default().fg(TEXT)));
|
||||
assert_eq!(cells[7].1, Style::default().fg(TEXT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_session_text_skips_decorative_prefixes() {
|
||||
let rows = build_session_render_rows(&[SessionGroup {
|
||||
@@ -902,4 +1449,26 @@ mod tests {
|
||||
assert!(!text.contains("┌ "));
|
||||
assert!(!text.contains("│ "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_groups_are_truncated_as_a_whole() {
|
||||
let rows = build_session_render_rows(&[SessionGroup {
|
||||
source: SessionSource::Executor,
|
||||
stream: SessionStream::Stdout,
|
||||
title: "Command".to_string(),
|
||||
tag: Some("alpha".to_string()),
|
||||
lines: (1..=10).map(|idx| format!("line {idx}")).collect(),
|
||||
run_id: 1,
|
||||
}]);
|
||||
|
||||
let body_lines = rows
|
||||
.iter()
|
||||
.map(|row| row.text.as_str())
|
||||
.filter(|row| row.starts_with("│ "))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(body_lines.len(), 7);
|
||||
assert_eq!(body_lines[0], "│ ... 4 earlier lines omitted");
|
||||
assert_eq!(body_lines[1], "│ line 5");
|
||||
assert_eq!(body_lines[6], "│ line 10");
|
||||
}
|
||||
}
|
||||
|
||||
524
src/ui/scroll.rs
Normal file
524
src/ui/scroll.rs
Normal file
@@ -0,0 +1,524 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
};
|
||||
|
||||
const ARROW_INITIAL_DELAY: Duration = Duration::from_millis(500);
|
||||
const ARROW_REPEAT_INTERVAL: Duration = Duration::from_millis(200);
|
||||
const WHEEL_STREAK_TIMEOUT_MS: u128 = 150;
|
||||
const WHEEL_MIN_TICK_INTERVAL_MS: u128 = 6;
|
||||
const WHEEL_ACCEL_A: f64 = 0.8;
|
||||
const WHEEL_ACCEL_TAU: f64 = 3.0;
|
||||
const WHEEL_ACCEL_MAX: f64 = 6.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct VerticalScrollMetrics {
|
||||
pub content_lines: usize,
|
||||
pub viewport_lines: usize,
|
||||
pub track_lines: usize,
|
||||
pub show_arrows: bool,
|
||||
}
|
||||
|
||||
impl VerticalScrollMetrics {
|
||||
pub fn scroll_range(self) -> usize {
|
||||
self.content_lines.saturating_sub(self.viewport_lines)
|
||||
}
|
||||
|
||||
pub fn effective_track_lines(self) -> usize {
|
||||
self.track_lines
|
||||
.saturating_sub(if self.show_arrows && self.track_lines >= 2 {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
})
|
||||
}
|
||||
|
||||
pub fn virtual_track_size(self) -> usize {
|
||||
self.effective_track_lines().saturating_mul(2)
|
||||
}
|
||||
|
||||
pub fn is_scrollable(self) -> bool {
|
||||
self.content_lines > self.viewport_lines && self.effective_track_lines() > 0
|
||||
}
|
||||
|
||||
pub fn thumb_size(self) -> usize {
|
||||
let virtual_track = self.virtual_track_size();
|
||||
if virtual_track == 0 {
|
||||
return 0;
|
||||
}
|
||||
if !self.is_scrollable() {
|
||||
return virtual_track;
|
||||
}
|
||||
|
||||
let calculated = (virtual_track.saturating_mul(self.viewport_lines)) / self.content_lines;
|
||||
calculated.clamp(1, virtual_track)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum VerticalScrollHit {
|
||||
Thumb,
|
||||
TrackBefore,
|
||||
TrackAfter,
|
||||
ArrowStart,
|
||||
ArrowEnd,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ScrollUnit {
|
||||
Absolute,
|
||||
Viewport,
|
||||
#[allow(dead_code)]
|
||||
Content,
|
||||
#[allow(dead_code)]
|
||||
Step,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct DragState {
|
||||
pub offset_virtual: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ArrowDirection {
|
||||
Backward,
|
||||
Forward,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ArrowHoldState {
|
||||
direction: ArrowDirection,
|
||||
next_repeat_at: Instant,
|
||||
repeated_initial_jump: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct VerticalScrollState {
|
||||
pub position_lines: usize,
|
||||
pub content_lines: usize,
|
||||
pub viewport_lines: usize,
|
||||
pub wheel_accumulator: f64,
|
||||
pub last_wheel_tick: Option<Instant>,
|
||||
pub drag_state: Option<DragState>,
|
||||
pub show_arrows: bool,
|
||||
wheel_intervals_ms: [u128; 3],
|
||||
wheel_interval_count: usize,
|
||||
arrow_hold: Option<ArrowHoldState>,
|
||||
}
|
||||
|
||||
impl VerticalScrollState {
|
||||
pub fn new(show_arrows: bool) -> Self {
|
||||
Self {
|
||||
position_lines: 0,
|
||||
content_lines: 0,
|
||||
viewport_lines: 0,
|
||||
wheel_accumulator: 0.0,
|
||||
last_wheel_tick: None,
|
||||
drag_state: None,
|
||||
show_arrows,
|
||||
wheel_intervals_ms: [0; 3],
|
||||
wheel_interval_count: 0,
|
||||
arrow_hold: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metrics(&self, track_lines: usize) -> VerticalScrollMetrics {
|
||||
VerticalScrollMetrics {
|
||||
content_lines: self.content_lines,
|
||||
viewport_lines: self.viewport_lines,
|
||||
track_lines,
|
||||
show_arrows: self.show_arrows,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_content_viewport(&mut self, content_lines: usize, viewport_lines: usize) {
|
||||
self.content_lines = content_lines;
|
||||
self.viewport_lines = viewport_lines;
|
||||
self.position_lines = self.position_lines.min(self.scroll_range());
|
||||
}
|
||||
|
||||
pub fn scroll_range(&self) -> usize {
|
||||
self.content_lines.saturating_sub(self.viewport_lines)
|
||||
}
|
||||
|
||||
pub fn set_position(&mut self, value: usize) -> bool {
|
||||
let clamped = value.min(self.scroll_range());
|
||||
if clamped == self.position_lines {
|
||||
return false;
|
||||
}
|
||||
self.position_lines = clamped;
|
||||
true
|
||||
}
|
||||
|
||||
fn set_position_float(&mut self, value: f64) -> bool {
|
||||
let clamped = value.clamp(0.0, self.scroll_range() as f64).round() as usize;
|
||||
self.set_position(clamped)
|
||||
}
|
||||
|
||||
pub fn scroll_by(&mut self, delta: f64, unit: ScrollUnit, step: usize) -> bool {
|
||||
let multiplier = match unit {
|
||||
ScrollUnit::Absolute => 1.0,
|
||||
ScrollUnit::Viewport => self.viewport_lines.max(1) as f64,
|
||||
ScrollUnit::Content => self.content_lines.max(1) as f64,
|
||||
ScrollUnit::Step => step.max(1) as f64,
|
||||
};
|
||||
self.set_position_float(self.position_lines as f64 + delta * multiplier)
|
||||
}
|
||||
|
||||
pub fn follow_bottom(&mut self) -> bool {
|
||||
self.set_position(self.scroll_range())
|
||||
}
|
||||
|
||||
pub fn start_arrow_hold(&mut self, hit: VerticalScrollHit, now: Instant) -> bool {
|
||||
let direction = match hit {
|
||||
VerticalScrollHit::ArrowStart => ArrowDirection::Backward,
|
||||
VerticalScrollHit::ArrowEnd => ArrowDirection::Forward,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
self.arrow_hold = Some(ArrowHoldState {
|
||||
direction,
|
||||
next_repeat_at: now + ARROW_INITIAL_DELAY,
|
||||
repeated_initial_jump: false,
|
||||
});
|
||||
|
||||
let delta = match direction {
|
||||
ArrowDirection::Backward => -0.5,
|
||||
ArrowDirection::Forward => 0.5,
|
||||
};
|
||||
self.scroll_by(delta, ScrollUnit::Viewport, 1)
|
||||
}
|
||||
|
||||
pub fn stop_arrow_hold(&mut self) {
|
||||
self.arrow_hold = None;
|
||||
}
|
||||
|
||||
pub fn arrow_hold_active(&self) -> bool {
|
||||
self.arrow_hold.is_some()
|
||||
}
|
||||
|
||||
pub fn drag_active(&self) -> bool {
|
||||
self.drag_state.is_some()
|
||||
}
|
||||
|
||||
pub fn tick_arrow_hold(&mut self, now: Instant) -> bool {
|
||||
let Some(mut hold) = self.arrow_hold else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
while now >= hold.next_repeat_at {
|
||||
let delta = match (hold.direction, hold.repeated_initial_jump) {
|
||||
(ArrowDirection::Backward, false) => -0.5,
|
||||
(ArrowDirection::Forward, false) => 0.5,
|
||||
(ArrowDirection::Backward, true) => -0.2,
|
||||
(ArrowDirection::Forward, true) => 0.2,
|
||||
};
|
||||
changed |= self.scroll_by(delta, ScrollUnit::Viewport, 1);
|
||||
hold.repeated_initial_jump = true;
|
||||
hold.next_repeat_at += ARROW_REPEAT_INTERVAL;
|
||||
}
|
||||
self.arrow_hold = Some(hold);
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn apply_wheel_tick(&mut self, direction: i8, now: Instant) -> bool {
|
||||
let delta_ms = self
|
||||
.last_wheel_tick
|
||||
.map(|last| now.saturating_duration_since(last).as_millis())
|
||||
.unwrap_or(u128::MAX);
|
||||
|
||||
let multiplier = if delta_ms == u128::MAX || delta_ms > WHEEL_STREAK_TIMEOUT_MS {
|
||||
self.wheel_interval_count = 0;
|
||||
1.0
|
||||
} else if delta_ms < WHEEL_MIN_TICK_INTERVAL_MS {
|
||||
return false;
|
||||
} else {
|
||||
self.push_wheel_interval(delta_ms);
|
||||
let average_ms = self.average_wheel_interval_ms();
|
||||
let velocity = 100.0 / average_ms;
|
||||
let x = velocity / WHEEL_ACCEL_TAU;
|
||||
(1.0 + WHEEL_ACCEL_A * (x.exp() - 1.0)).min(WHEEL_ACCEL_MAX)
|
||||
};
|
||||
|
||||
self.last_wheel_tick = Some(now);
|
||||
self.wheel_accumulator += direction as f64 * multiplier;
|
||||
|
||||
let integer_scroll = self.wheel_accumulator.trunc() as isize;
|
||||
if integer_scroll == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.wheel_accumulator -= integer_scroll as f64;
|
||||
self.scroll_by(integer_scroll as f64, ScrollUnit::Absolute, 1)
|
||||
}
|
||||
|
||||
fn push_wheel_interval(&mut self, delta_ms: u128) {
|
||||
if self.wheel_interval_count < self.wheel_intervals_ms.len() {
|
||||
self.wheel_intervals_ms[self.wheel_interval_count] = delta_ms;
|
||||
self.wheel_interval_count += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
self.wheel_intervals_ms.rotate_left(1);
|
||||
self.wheel_intervals_ms[self.wheel_intervals_ms.len() - 1] = delta_ms;
|
||||
}
|
||||
|
||||
fn average_wheel_interval_ms(&self) -> f64 {
|
||||
let total: u128 = self.wheel_intervals_ms[..self.wheel_interval_count]
|
||||
.iter()
|
||||
.copied()
|
||||
.sum();
|
||||
total as f64 / self.wheel_interval_count.max(1) as f64
|
||||
}
|
||||
|
||||
pub fn thumb_start(&self, metrics: VerticalScrollMetrics) -> usize {
|
||||
let virtual_track = metrics.virtual_track_size();
|
||||
let thumb_size = metrics.thumb_size();
|
||||
let scroll_range = metrics.scroll_range();
|
||||
|
||||
if virtual_track == 0 || scroll_range == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
((self.position_lines as f64 / scroll_range as f64) * (virtual_track - thumb_size) as f64)
|
||||
.round() as usize
|
||||
}
|
||||
|
||||
pub fn hit_test(&self, metrics: VerticalScrollMetrics, relative_row: u16) -> VerticalScrollHit {
|
||||
if metrics.track_lines == 0 {
|
||||
return VerticalScrollHit::None;
|
||||
}
|
||||
|
||||
let row = relative_row as usize;
|
||||
if metrics.show_arrows {
|
||||
if row == 0 {
|
||||
return VerticalScrollHit::ArrowStart;
|
||||
}
|
||||
if row + 1 == metrics.track_lines {
|
||||
return VerticalScrollHit::ArrowEnd;
|
||||
}
|
||||
}
|
||||
|
||||
let track_row = row.saturating_sub(if metrics.show_arrows { 1 } else { 0 });
|
||||
if track_row >= metrics.effective_track_lines() {
|
||||
return VerticalScrollHit::None;
|
||||
}
|
||||
|
||||
let thumb_start = self.thumb_start(metrics);
|
||||
let thumb_end = thumb_start + metrics.thumb_size();
|
||||
let cell_start = track_row * 2;
|
||||
let cell_end = cell_start + 2;
|
||||
|
||||
if thumb_end > cell_start && thumb_start < cell_end {
|
||||
VerticalScrollHit::Thumb
|
||||
} else if cell_end <= thumb_start {
|
||||
VerticalScrollHit::TrackBefore
|
||||
} else {
|
||||
VerticalScrollHit::TrackAfter
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_drag(&mut self, metrics: VerticalScrollMetrics, relative_row: u16) -> bool {
|
||||
let hit = self.hit_test(metrics, relative_row);
|
||||
match hit {
|
||||
VerticalScrollHit::Thumb => {
|
||||
self.drag_state = Some(DragState {
|
||||
offset_virtual: self.pointer_drag_offset(metrics, relative_row),
|
||||
});
|
||||
false
|
||||
}
|
||||
VerticalScrollHit::TrackBefore | VerticalScrollHit::TrackAfter => {
|
||||
let changed = self.update_position_from_pointer(metrics, relative_row, None);
|
||||
self.drag_state = Some(DragState {
|
||||
offset_virtual: self.pointer_drag_offset(metrics, relative_row),
|
||||
});
|
||||
changed
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drag_to(&mut self, metrics: VerticalScrollMetrics, relative_row: u16) -> bool {
|
||||
let offset_virtual = self.drag_state.map(|state| state.offset_virtual);
|
||||
self.update_position_from_pointer(metrics, relative_row, offset_virtual)
|
||||
}
|
||||
|
||||
pub fn stop_drag(&mut self) {
|
||||
self.drag_state = None;
|
||||
}
|
||||
|
||||
fn pointer_drag_offset(&self, metrics: VerticalScrollMetrics, relative_row: u16) -> usize {
|
||||
let virtual_mouse = self.pointer_virtual_position(metrics, relative_row);
|
||||
let thumb_start = self.thumb_start(metrics);
|
||||
let thumb_size = metrics.thumb_size();
|
||||
|
||||
virtual_mouse.saturating_sub(thumb_start).min(thumb_size)
|
||||
}
|
||||
|
||||
fn pointer_virtual_position(&self, metrics: VerticalScrollMetrics, relative_row: u16) -> usize {
|
||||
let track_row = relative_row as usize - if metrics.show_arrows { 1 } else { 0 };
|
||||
let track_size = metrics.effective_track_lines();
|
||||
if track_size == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let clamped = track_row.min(track_size);
|
||||
(clamped * 2).min(metrics.virtual_track_size())
|
||||
}
|
||||
|
||||
fn update_position_from_pointer(
|
||||
&mut self,
|
||||
metrics: VerticalScrollMetrics,
|
||||
relative_row: u16,
|
||||
offset_virtual: Option<usize>,
|
||||
) -> bool {
|
||||
let virtual_track = metrics.virtual_track_size();
|
||||
let thumb_size = metrics.thumb_size();
|
||||
let scroll_range = metrics.scroll_range();
|
||||
|
||||
if virtual_track == 0 || scroll_range == 0 {
|
||||
return self.set_position(0);
|
||||
}
|
||||
|
||||
let virtual_mouse = self.pointer_virtual_position(metrics, relative_row);
|
||||
let max_thumb_start = virtual_track.saturating_sub(thumb_size);
|
||||
let desired_thumb_start = virtual_mouse
|
||||
.saturating_sub(offset_virtual.unwrap_or(thumb_size / 2))
|
||||
.min(max_thumb_start);
|
||||
let ratio = if max_thumb_start == 0 {
|
||||
0.0
|
||||
} else {
|
||||
desired_thumb_start as f64 / max_thumb_start as f64
|
||||
};
|
||||
|
||||
self.set_position_float(ratio * scroll_range as f64)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct VerticalScrollStyles {
|
||||
pub track: Style,
|
||||
pub thumb: Style,
|
||||
pub arrows: Style,
|
||||
}
|
||||
|
||||
pub(crate) fn render_vertical_scrollbar(
|
||||
rect: Rect,
|
||||
state: &VerticalScrollState,
|
||||
styles: VerticalScrollStyles,
|
||||
) -> Vec<Line<'static>> {
|
||||
let metrics = state.metrics(rect.height as usize);
|
||||
let mut lines = Vec::with_capacity(rect.height as usize);
|
||||
let thumb_start = state.thumb_start(metrics);
|
||||
let thumb_end = thumb_start + metrics.thumb_size();
|
||||
|
||||
for row in 0..rect.height as usize {
|
||||
if metrics.show_arrows && row == 0 {
|
||||
lines.push(Line::from(Span::styled("▲", styles.arrows)));
|
||||
continue;
|
||||
}
|
||||
if metrics.show_arrows && row + 1 == metrics.track_lines {
|
||||
lines.push(Line::from(Span::styled("▼", styles.arrows)));
|
||||
continue;
|
||||
}
|
||||
|
||||
let track_row = row.saturating_sub(if metrics.show_arrows { 1 } else { 0 });
|
||||
let cell_start = track_row * 2;
|
||||
let cell_end = cell_start + 2;
|
||||
let thumb_start_in_cell = thumb_start.max(cell_start);
|
||||
let thumb_end_in_cell = thumb_end.min(cell_end);
|
||||
let coverage = thumb_end_in_cell.saturating_sub(thumb_start_in_cell);
|
||||
|
||||
let (symbol, style) = if coverage >= 2 {
|
||||
("█", styles.thumb)
|
||||
} else if coverage == 1 {
|
||||
if thumb_start_in_cell == cell_start {
|
||||
("▀", styles.thumb)
|
||||
} else {
|
||||
("▄", styles.thumb)
|
||||
}
|
||||
} else {
|
||||
("│", styles.track)
|
||||
};
|
||||
|
||||
lines.push(Line::from(Span::styled(symbol.to_string(), style)));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thumb_math_reaches_bottom_exactly() {
|
||||
let mut state = VerticalScrollState::new(false);
|
||||
state.set_content_viewport(100, 10);
|
||||
state.set_position(90);
|
||||
let metrics = state.metrics(10);
|
||||
|
||||
assert_eq!(metrics.thumb_size(), 2);
|
||||
assert_eq!(state.thumb_start(metrics), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_cell_thumb_uses_partial_glyphs() {
|
||||
let mut state = VerticalScrollState::new(false);
|
||||
state.set_content_viewport(8, 3);
|
||||
state.set_position(1);
|
||||
|
||||
let lines = render_vertical_scrollbar(
|
||||
Rect::new(0, 0, 1, 4),
|
||||
&state,
|
||||
VerticalScrollStyles {
|
||||
track: Style::default(),
|
||||
thumb: Style::default(),
|
||||
arrows: Style::default(),
|
||||
},
|
||||
);
|
||||
|
||||
let rendered = lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.first()
|
||||
.map(|span| span.content.as_ref())
|
||||
.unwrap_or("")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(rendered
|
||||
.iter()
|
||||
.any(|symbol| *symbol == "▀" || *symbol == "▄"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_acceleration_ignores_duplicate_ticks_and_accumulates() {
|
||||
let start = Instant::now();
|
||||
let mut state = VerticalScrollState::new(false);
|
||||
state.set_content_viewport(200, 20);
|
||||
|
||||
assert!(state.apply_wheel_tick(1, start));
|
||||
assert!(!state.apply_wheel_tick(1, start + Duration::from_millis(2)));
|
||||
let changed = state.apply_wheel_tick(1, start + Duration::from_millis(20));
|
||||
|
||||
assert!(changed);
|
||||
assert!(state.position_lines >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_click_sets_drag_and_jumps() {
|
||||
let mut state = VerticalScrollState::new(false);
|
||||
state.set_content_viewport(100, 10);
|
||||
let metrics = state.metrics(10);
|
||||
|
||||
assert!(state.begin_drag(metrics, 8));
|
||||
assert!(state.drag_state.is_some());
|
||||
assert!(state.position_lines > 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user