initial commit
This commit is contained in:
commit
414140b962
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Living Software LTD
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
BIN
doc/Screenshot 2026-06-20 at 14.28.29.png
Normal file
BIN
doc/Screenshot 2026-06-20 at 14.28.29.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
BIN
doc/spike-screenshot.jpeg
Normal file
BIN
doc/spike-screenshot.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
172
doc/spike-smells.md
Normal file
172
doc/spike-smells.md
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
|
||||||
|
# Asked qwen3.6 35B about code smells
|
||||||
|
|
||||||
|
Analyze the code in src/ for code smells and improvements.
|
||||||
|
|
||||||
|
Qwen:
|
||||||
|
|
||||||
|
# Here's my analysis of the codebase:
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Code Smells & Improvements
|
||||||
|
|
||||||
|
### 1. Duplicated Constants (REFACTOR_FOCI)
|
||||||
|
|
||||||
|
Location: src/state.ts and src/ui.ts
|
||||||
|
|
||||||
|
Both files define their own copy of REFACTOR_FOCI:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// state.ts
|
||||||
|
export const REFACTOR_FOCI: RefactorFocus[] = [...];
|
||||||
|
// ui.ts (line 16)
|
||||||
|
const REFACTOR_FOCI: RefactorFocus[] = [...];
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix: Export the constant from state.ts and import it in ui.ts. This is a direct DRY violation.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 2. canTransition is Dead Code
|
||||||
|
|
||||||
|
canTransition() in state.ts is never called by any tool or event handler. The phase enforcement in index.ts is done via imperative if checks in the go-to-refactor tool and the tool_call event
|
||||||
|
handler. The function sits there with full test coverage but zero usage.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- Delete it if it's not needed — the enforcement logic is already in the tools.
|
||||||
|
- Use it in go-to-refactor instead of the manual if/else checks, making the tool logic cleaner and the guard centralized.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 3. redToGreen is Never Called Directly
|
||||||
|
|
||||||
|
redToGreen() is only called internally inside recordTestResult() — it's never invoked by any tool. It's effectively a private helper, yet it's exported and has its own test suite.
|
||||||
|
|
||||||
|
Fix: Either make it private (or just not export it) and remove its standalone tests, or keep it exported if you want it as a reusable state-machine primitive.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 4. Magic String "npm test"
|
||||||
|
|
||||||
|
go-to-green hardcodes const testCommand = "npm test 2>&1"; while run-tests allows a custom command. The go-to-green tool should also accept an optional command parameter for consistency.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 5. persistState / restoreState — Fragile Serialization
|
||||||
|
|
||||||
|
The state persistence relies on pi.appendEntry / ctx.sessionManager.getEntries() with string-based filtering (e.type === "custom" && e.customType === "tdd-cycle"). This is fragile:
|
||||||
|
- No versioning — if the CycleState interface changes, old entries may corrupt state.
|
||||||
|
- No deduplication logic beyond .pop() — if multiple entries exist, only the last one is kept (which is correct, but the intent isn't explicit).
|
||||||
|
- restoreState is called at the start of every tool execution, meaning state is reloaded on each call — this is fine for a TDD cycle, but worth noting.
|
||||||
|
|
||||||
|
Suggestion: Add a version field to CycleState and handle version mismatches gracefully.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 6. buildSteeringMessage is a Long Switch
|
||||||
|
|
||||||
|
This function has 5 if branches that could be replaced with a Record<RefactorFocus, (decision: RefactorDecision) => string> mapping. This would make it data-driven and easier to extend:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const STEERING_MESSAGES: Record<RefactorFocus, (d: RefactorDecision) => string> = {
|
||||||
|
"next-test": () => "...",
|
||||||
|
audit: () => "...",
|
||||||
|
// etc.
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 7. Repeated ctx.ui.setWidget Calls
|
||||||
|
|
||||||
|
Every tool and event handler calls ctx.ui.setWidget("tdd-status", ...) with similar patterns. This could be extracted into a helper like updateStatusWidget(ctx) to reduce repetition and ensure
|
||||||
|
consistency.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 8. renderResult Type Guards are Repetitive
|
||||||
|
|
||||||
|
All four tools have the same boilerplate in renderResult:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const details = result.details as { ... } | undefined;
|
||||||
|
if (!details) {
|
||||||
|
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix: Extract this into a shared renderResultFallback helper.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 9. go-to-refactor Non-TUI Fallback is Silent
|
||||||
|
|
||||||
|
When ctx.mode !== "tui", the tool silently defaults to { focus: "next-test" } without any user confirmation. This could be confusing — the developer has no way to choose a different refactoring
|
||||||
|
focus.
|
||||||
|
|
||||||
|
Suggestion: In non-TUI mode, return the REFACTOR phase status without auto-advancing, and let the developer call the tool again with an explicit parameter.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 10. invalidate in UI Component is a No-Op
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return { render, invalidate: () => {}, handleInput };
|
||||||
|
```
|
||||||
|
|
||||||
|
The invalidate callback does nothing. If the TUI framework expects it to trigger a re-render, this is a bug. If it's intentionally unused, document why.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 11. recordTestResult Returns TransitionResult but Caller Ignores valid/error
|
||||||
|
|
||||||
|
In index.ts, recordTestResult is called and its state is used, but the valid and error fields are discarded:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const transition = recordTestResult(state, passed);
|
||||||
|
state = transition.state; // valid/error ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
If recordTestResult returns invalid (e.g., called from REFACTOR phase), the state is still updated. The tool should check transition.valid and handle errors.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### 12. go-to-green Hardcodes Timeout
|
||||||
|
|
||||||
|
Both go-to-green and run-tests use timeout: 60_000. This is fine, but consider making it configurable via a tool parameter for projects with slower test suites.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
### Summary Table
|
||||||
|
|
||||||
|
┌────┬───────────────────────────────────────────┬──────────┬────────┐
|
||||||
|
│ # │ Smell │ Severity │ Effort │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 1 │ Duplicated REFACTOR_FOCI │ Low │ 5 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 2 │ Dead canTransition │ Medium │ 10 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 3 │ Unused export redToGreen │ Low │ 5 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 4 │ Hardcoded "npm test" in go-to-green │ Low │ 5 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 5 │ Fragile state persistence (no versioning) │ Medium │ 20 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 6 │ Long switch → data-driven mapping │ Low │ 10 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 7 │ Repeated setWidget calls │ Low │ 10 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 8 │ Repetitive renderResult guards │ Low │ 10 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 9 │ Silent non-TUI fallback │ Medium │ 15 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 10 │ No-op invalidate │ Low │ 5 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 11 │ Ignored valid/error from recordTestResult │ High │ 10 min │
|
||||||
|
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||||
|
│ 12 │ Hardcoded timeout │ Low │ 5 min │
|
||||||
|
└────┴───────────────────────────────────────────┴──────────┴────────┘
|
||||||
|
|
||||||
|
The highest priority fix is #11 — the recordTestResult return value's valid field is silently ignored, which means invalid state transitions (e.g., recording test results while in REFACTOR phase)
|
||||||
|
would silently corrupt state.
|
||||||
3350
package-lock.json
generated
Normal file
3350
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-tdd-cycle",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Pi extension that enforces the red-green-refactor TDD cycle",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"pi-package",
|
||||||
|
"tdd",
|
||||||
|
"red-green-refactor",
|
||||||
|
"extension"
|
||||||
|
],
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./src/index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "latest",
|
||||||
|
"@earendil-works/pi-tui": "latest",
|
||||||
|
"@types/node": "latest",
|
||||||
|
"typebox": "latest",
|
||||||
|
"typescript": "latest",
|
||||||
|
"vitest": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
495
src/index.ts
Normal file
495
src/index.ts
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
/**
|
||||||
|
* TDD Cycle Extension
|
||||||
|
*
|
||||||
|
* Enforces the red-green-refactor TDD cycle:
|
||||||
|
* 🔴 RED → Write a failing test, then implement to make it pass
|
||||||
|
* 🟢 GREEN → Tests pass, ready for refactoring
|
||||||
|
* 🔀 REFACTOR → Developer chooses what to refactor; agent executes;
|
||||||
|
* loop back to developer until "next test" chosen
|
||||||
|
*
|
||||||
|
* Tools:
|
||||||
|
* go-to-green - Run tests and transition based on result
|
||||||
|
* go-to-refactor - Enter the developer decision phase (or report back)
|
||||||
|
* run-tests - Run tests without phase transition
|
||||||
|
* tdd-status - Show current phase and state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { Text } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createInitialState,
|
||||||
|
recordTestResult,
|
||||||
|
greenToRefactor,
|
||||||
|
applyRefactorDecision,
|
||||||
|
phaseEmoji,
|
||||||
|
phaseDescription,
|
||||||
|
phaseGuidelines,
|
||||||
|
type CycleState,
|
||||||
|
type RefactorDecision,
|
||||||
|
} from "./state.js";
|
||||||
|
import { createRefactorDecisionComponent } from "./ui.js";
|
||||||
|
|
||||||
|
export default function tddCycle(pi: ExtensionAPI): void {
|
||||||
|
let state: CycleState = createInitialState();
|
||||||
|
|
||||||
|
function persistState(): void {
|
||||||
|
pi.appendEntry("tdd-cycle", {
|
||||||
|
phase: state.phase,
|
||||||
|
testDescription: state.testDescription,
|
||||||
|
testsRan: state.testsRan,
|
||||||
|
testsPassed: state.testsPassed,
|
||||||
|
testsWritten: state.testsWritten,
|
||||||
|
lastDecision: state.lastDecision,
|
||||||
|
customRefactoringInstruction: state.customRefactoringInstruction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreState(ctx: ExtensionContext): void {
|
||||||
|
const entries = ctx.sessionManager.getEntries();
|
||||||
|
const tddEntry = entries
|
||||||
|
.filter(
|
||||||
|
(e: { type: string; customType?: string }) =>
|
||||||
|
e.type === "custom" && e.customType === "tdd-cycle",
|
||||||
|
)
|
||||||
|
.pop() as { data?: Partial<CycleState> } | undefined;
|
||||||
|
|
||||||
|
if (tddEntry?.data) {
|
||||||
|
state = {
|
||||||
|
...createInitialState(),
|
||||||
|
...tddEntry.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMessage(): string {
|
||||||
|
return phaseDescription(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function guidelinesMessage(): string {
|
||||||
|
return phaseGuidelines(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteeringMessage(decision: RefactorDecision): string {
|
||||||
|
if (decision.focus === "next-test") {
|
||||||
|
return (
|
||||||
|
"🔀 **Next test.** Phase changed: REFACTOR → **RED**.\n\n" +
|
||||||
|
"Write the next failing test."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (decision.focus === "audit") {
|
||||||
|
return (
|
||||||
|
"🔍 **Code audit requested.**\n\n" +
|
||||||
|
"Review the implementation:\n" +
|
||||||
|
"1. Identify any code smells\n" +
|
||||||
|
"2. Is all the code actually required?\n" +
|
||||||
|
"3. Is the implementation minimal — does it only implement what the tests require?\n" +
|
||||||
|
"4. Report your findings back to the developer\n\n" +
|
||||||
|
"After your audit, call **go-to-refactor** again to report back."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (decision.focus === "tests") {
|
||||||
|
return (
|
||||||
|
"🛠 **Refactor the tests.**\n\n" +
|
||||||
|
"Improve test structure and coverage while keeping tests passing.\n" +
|
||||||
|
"After refactoring, run tests to verify they still pass, then call **go-to-refactor**."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (decision.focus === "implementation") {
|
||||||
|
return (
|
||||||
|
"🛠 **Refactor the implementation.**\n\n" +
|
||||||
|
"Improve production code structure.\n" +
|
||||||
|
"After refactoring, run tests to verify they still pass, then call **go-to-refactor**."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (decision.focus === "custom" && decision.customInstruction) {
|
||||||
|
return (
|
||||||
|
"🔀 **Custom refactoring instruction:**\n\n" +
|
||||||
|
decision.customInstruction +
|
||||||
|
"\n\nAfter refactoring, run tests to verify they still pass, then call **go-to-refactor**."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "Refactoring decision applied. Call go-to-refactor when done.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool: go-to-green ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "go-to-green",
|
||||||
|
label: "Go to Green",
|
||||||
|
description:
|
||||||
|
"Run tests to verify implementation. If tests pass, transitions to GREEN phase. " +
|
||||||
|
"If tests fail, stays in RED with feedback. Use this after writing a failing test and its implementation.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
testDescription: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description:
|
||||||
|
"Brief description of what test(s) to run (e.g., 'user login test'). " +
|
||||||
|
"Used for display purposes.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
restoreState(ctx);
|
||||||
|
|
||||||
|
const testDesc = params.testDescription ?? state.testDescription;
|
||||||
|
if (testDesc) {
|
||||||
|
state.testDescription = testDesc;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.setWidget("tdd-status", [
|
||||||
|
ctx.ui.theme.fg("accent", statusMessage()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const testCommand = "npm test 2>&1";
|
||||||
|
const result = await pi.exec("bash", ["-c", testCommand], {
|
||||||
|
signal: ctx.signal,
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const passed = result.code === 0;
|
||||||
|
const transition = recordTestResult(state, passed);
|
||||||
|
state = transition.state;
|
||||||
|
|
||||||
|
ctx.ui.setWidget("tdd-status", [
|
||||||
|
ctx.ui.theme.fg("accent", statusMessage()),
|
||||||
|
ctx.ui.theme.fg(passed ? "success" : "error", passed ? "✓ Tests passed" : "✗ Tests failed"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (transition.state.phase === "GREEN") {
|
||||||
|
pi.sendMessage(
|
||||||
|
{
|
||||||
|
customType: "tdd-cycle",
|
||||||
|
content:
|
||||||
|
"🟢 **GREEN PHASE ACHIEVED!**\n\n" +
|
||||||
|
(transition.message ?? "") + "\n\n" +
|
||||||
|
"The developer must now decide what to refactor.\n" +
|
||||||
|
"Call **go-to-refactor** to enter the refactoring decision phase.",
|
||||||
|
display: true,
|
||||||
|
},
|
||||||
|
{ triggerTurn: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
persistState();
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: transition.message ?? "Tests executed.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
passed,
|
||||||
|
phase: transition.state.phase,
|
||||||
|
exitCode: result.code,
|
||||||
|
output: result.stdout.slice(0, 50_000),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
renderCall(args, theme, _context) {
|
||||||
|
const text = theme.fg("toolTitle", theme.bold("go-to-green ")) + (args.testDescription ? theme.fg("muted", args.testDescription) : "");
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
renderResult(result, _options, theme, _context) {
|
||||||
|
const details = result.details as { passed?: boolean; phase?: string } | undefined;
|
||||||
|
if (!details) {
|
||||||
|
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||||
|
}
|
||||||
|
const phase = details.phase ? phaseEmoji(details.phase as any) : "";
|
||||||
|
const status = details.passed
|
||||||
|
? theme.fg("success", phase + " Tests passed")
|
||||||
|
: theme.fg("error", phase + " Tests failed");
|
||||||
|
return new Text(status, 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tool: go-to-refactor ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "go-to-refactor",
|
||||||
|
label: "Go to Refactor",
|
||||||
|
description:
|
||||||
|
"Enter the REFACTOR phase for developer decisions, or report back after refactoring work is done. " +
|
||||||
|
"When called from GREEN: enters REFACTOR and prompts developer. " +
|
||||||
|
"When called from REFACTOR: re-prompts the developer for a new decision. " +
|
||||||
|
"Call this after refactoring work is complete and tests pass again.",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||||
|
restoreState(ctx);
|
||||||
|
|
||||||
|
if (state.phase === "GREEN") {
|
||||||
|
// First entry: transition from GREEN to REFACTOR
|
||||||
|
const transition = greenToRefactor(state);
|
||||||
|
state = transition.state;
|
||||||
|
persistState();
|
||||||
|
} else if (state.phase !== "REFACTOR") {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Cannot enter REFACTOR from phase " + state.phase + ". Must be in GREEN or already in REFACTOR.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { phase: state.phase },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.setWidget("tdd-status", [
|
||||||
|
ctx.ui.theme.fg("accent", statusMessage()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (ctx.mode === "tui" && ctx.hasUI) {
|
||||||
|
const decision = await ctx.ui.custom<RefactorDecision | null>(
|
||||||
|
createRefactorDecisionComponent(state.testDescription),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (decision) {
|
||||||
|
// Get custom instruction text if needed
|
||||||
|
let effectiveDecision = decision;
|
||||||
|
if (decision.focus === "custom") {
|
||||||
|
const customText = await ctx.ui.input(
|
||||||
|
"Custom instruction:",
|
||||||
|
"e.g. 'Extract the service class'",
|
||||||
|
);
|
||||||
|
if (customText?.trim()) {
|
||||||
|
effectiveDecision = { focus: "custom", customInstruction: customText.trim() };
|
||||||
|
} else {
|
||||||
|
// Cancelled or empty — default to implementation
|
||||||
|
effectiveDecision = { focus: "implementation" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyResult = applyRefactorDecision(state, effectiveDecision);
|
||||||
|
state = applyResult.state;
|
||||||
|
persistState();
|
||||||
|
|
||||||
|
const steeringMessage = buildSteeringMessage(effectiveDecision);
|
||||||
|
|
||||||
|
pi.sendMessage(
|
||||||
|
{
|
||||||
|
customType: "tdd-cycle",
|
||||||
|
content: steeringMessage,
|
||||||
|
display: true,
|
||||||
|
},
|
||||||
|
{ triggerTurn: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: applyResult.message ?? "Refactoring decision applied.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
decision: effectiveDecision,
|
||||||
|
phase: state.phase,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Developer cancelled. Staying in REFACTOR phase.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { phase: "REFACTOR" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-TUI mode: fallback to next-test
|
||||||
|
if (state.phase === "REFACTOR") {
|
||||||
|
const fallbackDecision: RefactorDecision = { focus: "next-test" };
|
||||||
|
const applyResult = applyRefactorDecision(state, fallbackDecision);
|
||||||
|
state = applyResult.state;
|
||||||
|
persistState();
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: applyResult.message ?? "Non-TUI: advancing to next test." },
|
||||||
|
],
|
||||||
|
details: { phase: "RED" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Entered REFACTOR phase." }],
|
||||||
|
details: { phase: "REFACTOR" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
renderCall(_args, theme, _context) {
|
||||||
|
return new Text(theme.fg("toolTitle", theme.bold("go-to-refactor")), 0, 0);
|
||||||
|
},
|
||||||
|
renderResult(result, _options, theme, _context) {
|
||||||
|
const details = result.details as { phase?: string; decision?: RefactorDecision } | undefined;
|
||||||
|
if (!details) {
|
||||||
|
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||||
|
}
|
||||||
|
const phase = details.phase ? phaseEmoji(details.phase as any) : "";
|
||||||
|
const text = details.decision
|
||||||
|
? phase + " Refactored: " + details.decision.focus
|
||||||
|
: phase + " Entered refactor phase";
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tool: run-tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "run-tests",
|
||||||
|
label: "Run Tests",
|
||||||
|
description:
|
||||||
|
"Run tests without phase transition. Useful for checking test status " +
|
||||||
|
"during development. Returns exit code and output.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
command: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description: "Custom test command to run (default: 'npm test').",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
restoreState(ctx);
|
||||||
|
|
||||||
|
const command = params.command ?? "npm test";
|
||||||
|
const result = await pi.exec("bash", ["-c", command], {
|
||||||
|
signal,
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const passed = result.code === 0;
|
||||||
|
|
||||||
|
ctx.ui.setWidget("tdd-status", [
|
||||||
|
ctx.ui.theme.fg("accent", statusMessage()),
|
||||||
|
ctx.ui.theme.fg(passed ? "success" : "error", passed ? "✓ Tests passed" : "✗ Tests failed"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: (passed ? "✓" : "✗") + " Tests " + (passed ? "passed" : "failed") + " (exit code: " + result.code + ")",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
passed,
|
||||||
|
exitCode: result.code,
|
||||||
|
output: result.stdout.slice(0, 50_000),
|
||||||
|
stderr: result.stderr.slice(0, 50_000),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
renderCall(args, theme, _context) {
|
||||||
|
const text = theme.fg("toolTitle", theme.bold("run-tests ")) + (args.command ? theme.fg("muted", args.command) : theme.fg("muted", "npm test"));
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
renderResult(result, _options, theme, _context) {
|
||||||
|
const details = result.details as { passed?: boolean } | undefined;
|
||||||
|
if (!details) {
|
||||||
|
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||||
|
}
|
||||||
|
const status = details.passed
|
||||||
|
? theme.fg("success", "✓ Tests passed")
|
||||||
|
: theme.fg("error", "✗ Tests failed");
|
||||||
|
return new Text(status, 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tool: tdd-status ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "tdd-status",
|
||||||
|
label: "TDD Status",
|
||||||
|
description:
|
||||||
|
"Show current TDD cycle phase and state. Returns the current phase, " +
|
||||||
|
"test count, and guidelines for what to do next.",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||||
|
restoreState(ctx);
|
||||||
|
|
||||||
|
const statusText = phaseDescription(state);
|
||||||
|
const guidelines = phaseGuidelines(state);
|
||||||
|
const testsWritten = state.testsWritten ?? 0;
|
||||||
|
|
||||||
|
ctx.ui.setWidget("tdd-status", [
|
||||||
|
ctx.ui.theme.fg("accent", statusText),
|
||||||
|
ctx.ui.theme.fg("dim", "Tests written: " + testsWritten),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: statusText + "\n\nTests written: " + testsWritten + "\n\n" + guidelines,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
phase: state.phase,
|
||||||
|
testsRan: state.testsRan,
|
||||||
|
testsPassed: state.testsPassed,
|
||||||
|
testsWritten,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
renderCall(_args, theme, _context) {
|
||||||
|
return new Text(theme.fg("toolTitle", theme.bold("tdd-status")), 0, 0);
|
||||||
|
},
|
||||||
|
renderResult(result, _options, theme, _context) {
|
||||||
|
const details = result.details as { phase?: string } | undefined;
|
||||||
|
if (!details) {
|
||||||
|
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||||
|
}
|
||||||
|
const phase = details.phase ? phaseEmoji(details.phase as any) : "";
|
||||||
|
return new Text(phase + " " + details.phase, 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Events ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
restoreState(ctx);
|
||||||
|
|
||||||
|
ctx.ui.setWidget("tdd-status", [
|
||||||
|
ctx.ui.theme.fg("accent", statusMessage()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (ctx.mode === "tui") {
|
||||||
|
ctx.ui.notify("TDD Cycle started in " + state.phase + " phase", "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow go-to-green during REFACTOR (to verify tests after refactoring)
|
||||||
|
// Only block: RED → go-to-refactor (must pass tests first)
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
if (state.phase === "RED" && event.toolName === "go-to-refactor") {
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason: "Cannot go to REFACTOR from RED phase. You must first make tests pass (GREEN phase). Call go-to-green after your implementation passes the test.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async () => {
|
||||||
|
return {
|
||||||
|
message: {
|
||||||
|
customType: "tdd-cycle-guidelines",
|
||||||
|
content: "\n\n[TDD CYCLE - " + state.phase + " PHASE]\n" + guidelinesMessage(),
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_end", async (_event, ctx) => {
|
||||||
|
ctx.ui.setWidget("tdd-status", [
|
||||||
|
ctx.ui.theme.fg("accent", statusMessage()),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async () => {
|
||||||
|
persistState();
|
||||||
|
});
|
||||||
|
}
|
||||||
337
src/state.ts
Normal file
337
src/state.ts
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* TDD Cycle State Machine
|
||||||
|
*
|
||||||
|
* Enforces the red-green-refactor cycle:
|
||||||
|
* RED → Write a failing test, then implement to make it pass
|
||||||
|
* GREEN → Tests pass, ready for refactoring
|
||||||
|
* REFACTOR → Developer chooses what to refactor
|
||||||
|
* After refactor: back to RED for next test (or GREEN if no new test)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Phase ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Phase = "RED" | "GREEN" | "REFACTOR";
|
||||||
|
|
||||||
|
export const PHASES: Phase[] = ["RED", "GREEN", "REFACTOR"];
|
||||||
|
|
||||||
|
// ── Refactor Focus ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RefactorFocus = "audit" | "tests" | "implementation" | "next-test" | "custom";
|
||||||
|
|
||||||
|
export const REFACTOR_FOCI: RefactorFocus[] = [
|
||||||
|
"audit",
|
||||||
|
"tests",
|
||||||
|
"implementation",
|
||||||
|
"next-test",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Decision Result ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RefactorDecision {
|
||||||
|
focus: RefactorFocus;
|
||||||
|
customInstruction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cycle State ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CycleState {
|
||||||
|
/** Current phase */
|
||||||
|
phase: Phase;
|
||||||
|
/** Test description (what we're trying to make pass) */
|
||||||
|
testDescription?: string;
|
||||||
|
/** Whether tests have been run in the current cycle */
|
||||||
|
testsRan?: boolean;
|
||||||
|
/** Whether tests passed in the current cycle */
|
||||||
|
testsPassed?: boolean;
|
||||||
|
/** Number of tests written so far in this session */
|
||||||
|
testsWritten?: number;
|
||||||
|
/** Last refactoring decision (if any) */
|
||||||
|
lastDecision?: RefactorDecision;
|
||||||
|
/** Custom refactoring instruction from developer */
|
||||||
|
customRefactoringInstruction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialState(): CycleState {
|
||||||
|
return {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: false,
|
||||||
|
testsPassed: false,
|
||||||
|
testsWritten: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transitions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TransitionResult {
|
||||||
|
/** New state */
|
||||||
|
state: CycleState;
|
||||||
|
/** Message to show to the LLM (optional) */
|
||||||
|
message?: string;
|
||||||
|
/** Whether the transition was valid */
|
||||||
|
valid: boolean;
|
||||||
|
/** Error message if transition was invalid */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition from RED to GREEN.
|
||||||
|
* Valid only when tests have been run and passed.
|
||||||
|
*/
|
||||||
|
export function redToGreen(state: CycleState): TransitionResult {
|
||||||
|
if (!state.testsRan) {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
valid: false,
|
||||||
|
error: "Cannot transition to GREEN: tests have not been run yet. Run tests first.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!state.testsPassed) {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
valid: false,
|
||||||
|
error: "Cannot transition to GREEN: tests did not pass. Stay in RED and make tests pass.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: { ...state, phase: "GREEN", testsRan: false, testsPassed: false },
|
||||||
|
message:
|
||||||
|
"✅ Tests pass! Phase changed: GREEN → REFACTOR. " +
|
||||||
|
"You should now refactor. Call go-to-refactor when ready.",
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition from GREEN to REFACTOR.
|
||||||
|
* Always valid.
|
||||||
|
*/
|
||||||
|
export function greenToRefactor(state: CycleState): TransitionResult {
|
||||||
|
return {
|
||||||
|
state: { ...state, phase: "REFACTOR" },
|
||||||
|
message:
|
||||||
|
"🔄 Phase changed: GREEN → REFACTOR. " +
|
||||||
|
"Developer will now decide what to refactor.",
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a refactoring decision.
|
||||||
|
* Only transitions to RED for "next-test"; other options stay in REFACTOR
|
||||||
|
* so the agent can do the actual refactoring work.
|
||||||
|
*/
|
||||||
|
export function applyRefactorDecision(
|
||||||
|
state: CycleState,
|
||||||
|
decision: RefactorDecision,
|
||||||
|
): TransitionResult {
|
||||||
|
const newState: CycleState = {
|
||||||
|
...state,
|
||||||
|
phase: "RED",
|
||||||
|
lastDecision: decision,
|
||||||
|
customRefactoringInstruction:
|
||||||
|
decision.focus === "custom" ? decision.customInstruction : undefined,
|
||||||
|
testsRan: false,
|
||||||
|
testsPassed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (decision.focus === "next-test") {
|
||||||
|
newState.testsWritten = (state.testsWritten ?? 0) + 1;
|
||||||
|
newState.phase = "RED";
|
||||||
|
let message = `🔀 Next test. Phase changed: REFACTOR → RED.`;
|
||||||
|
return { state: newState, message, valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For audit, tests, implementation, custom — stay in REFACTOR for agent to work
|
||||||
|
newState.phase = "REFACTOR";
|
||||||
|
let message = `🔀 Refactoring decision: ${decision.focus}.`;
|
||||||
|
if (decision.focus === "custom" && decision.customInstruction) {
|
||||||
|
message += ` Custom instruction: "${decision.customInstruction}"`;
|
||||||
|
}
|
||||||
|
message += " Phase: REFACTOR (working).";
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: newState,
|
||||||
|
message,
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that tests have been run and their result.
|
||||||
|
* This is called by the go-to-green tool after test execution.
|
||||||
|
*/
|
||||||
|
export function recordTestResult(
|
||||||
|
state: CycleState,
|
||||||
|
passed: boolean,
|
||||||
|
): TransitionResult {
|
||||||
|
if (state.phase !== "RED" && state.phase !== "GREEN") {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
valid: false,
|
||||||
|
error: `Cannot record test results in phase ${state.phase}. Must be in RED or GREEN.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState: CycleState = {
|
||||||
|
...state,
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: passed,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (passed && state.phase === "RED") {
|
||||||
|
// Tests passed while in RED → auto-transition to GREEN
|
||||||
|
// Keep testsRan/testsPassed from newState (they reflect the just-run tests)
|
||||||
|
const greenResult = redToGreen(newState);
|
||||||
|
return {
|
||||||
|
...greenResult,
|
||||||
|
state: { ...greenResult.state, testsRan: true, testsPassed: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passed) {
|
||||||
|
return {
|
||||||
|
state: newState,
|
||||||
|
message:
|
||||||
|
"❌ Tests failed. Stay in RED. Fix the implementation to make tests pass.",
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests passed while already in GREEN → stay in GREEN
|
||||||
|
return {
|
||||||
|
state: newState,
|
||||||
|
message: "✅ Tests still pass. Stay in GREEN.",
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a transition is valid before executing it.
|
||||||
|
*/
|
||||||
|
export function canTransition(
|
||||||
|
state: CycleState,
|
||||||
|
targetPhase: Phase,
|
||||||
|
): { valid: boolean; reason: string } {
|
||||||
|
if (state.phase === targetPhase) {
|
||||||
|
return { valid: true, reason: `Already in ${targetPhase}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase === "RED") {
|
||||||
|
if (targetPhase === "GREEN") {
|
||||||
|
const can = !!state.testsRan && !!state.testsPassed;
|
||||||
|
return {
|
||||||
|
valid: can,
|
||||||
|
reason: can
|
||||||
|
? "Tests passed, can go to GREEN"
|
||||||
|
: "Must run passing tests first",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (targetPhase === "REFACTOR") {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason:
|
||||||
|
"Cannot go directly from RED to REFACTOR. Must go through GREEN first.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase === "GREEN") {
|
||||||
|
if (targetPhase === "RED") {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason:
|
||||||
|
"Cannot go from GREEN back to RED. Complete refactoring first.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (targetPhase === "REFACTOR") {
|
||||||
|
return { valid: true, reason: "Can go to REFACTOR from GREEN" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase === "REFACTOR") {
|
||||||
|
if (targetPhase === "RED") {
|
||||||
|
return { valid: true, reason: "Can go to RED after refactoring decision" };
|
||||||
|
}
|
||||||
|
if (targetPhase === "GREEN") {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason:
|
||||||
|
"Cannot go from REFACTOR to GREEN. Must go to RED first (write next test).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, reason: `Invalid transition from ${state.phase} to ${targetPhase}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function phaseEmoji(phase: Phase): string {
|
||||||
|
switch (phase) {
|
||||||
|
case "RED":
|
||||||
|
return "🔴";
|
||||||
|
case "GREEN":
|
||||||
|
return "🟢";
|
||||||
|
case "REFACTOR":
|
||||||
|
return "🔀";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function phaseDescription(state: CycleState): string {
|
||||||
|
const emoji = phaseEmoji(state.phase);
|
||||||
|
const phase = state.phase;
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case "RED":
|
||||||
|
if (!state.testsRan) {
|
||||||
|
return `${emoji} RED — Write a failing test, then implement to make it pass.`;
|
||||||
|
}
|
||||||
|
if (state.testsPassed) {
|
||||||
|
return `${emoji} RED→GREEN — Tests passed! Ready for refactoring.`;
|
||||||
|
}
|
||||||
|
return `${emoji} RED — Tests failed. Fix implementation.`;
|
||||||
|
case "GREEN":
|
||||||
|
return `${emoji} GREEN — Tests pass. Ready for refactoring.`;
|
||||||
|
case "REFACTOR":
|
||||||
|
return `${emoji} REFACTOR — Developer decides what to refactor.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function phaseGuidelines(state: CycleState): string {
|
||||||
|
switch (state.phase) {
|
||||||
|
case "RED":
|
||||||
|
if (!state.testsRan) {
|
||||||
|
return `You are in RED phase.
|
||||||
|
- Write a failing test first (the test should fail before your implementation)
|
||||||
|
- Then write minimal implementation to make the test pass
|
||||||
|
- Run tests with bash to verify
|
||||||
|
- When tests pass, the system will move you to GREEN`;
|
||||||
|
}
|
||||||
|
if (state.testsPassed) {
|
||||||
|
return `You are in RED→GREEN transition. Tests passed!
|
||||||
|
- You should now call go-to-refactor to enter the REFACTOR phase
|
||||||
|
- Do NOT write a new test yet — refactor first`;
|
||||||
|
}
|
||||||
|
return `You are in RED phase. Tests failed.
|
||||||
|
- Improve your implementation to make tests pass
|
||||||
|
- Do NOT change the test — the test defines the requirement
|
||||||
|
- Run tests with bash to verify`;
|
||||||
|
case "GREEN":
|
||||||
|
return `You are in GREEN phase. Tests pass.
|
||||||
|
- Call go-to-refactor to enter the REFACTOR phase
|
||||||
|
- The developer will then decide what to refactor`;
|
||||||
|
case "REFACTOR":
|
||||||
|
if (state.lastDecision) {
|
||||||
|
return `You are in REFACTOR phase. The developer chose: ${state.lastDecision.focus}.
|
||||||
|
- Execute the refactoring work now
|
||||||
|
- After refactoring, run tests to verify they still pass
|
||||||
|
- Then call go-to-refactor again to report back to the developer`;
|
||||||
|
}
|
||||||
|
return `You are in REFACTOR phase. The developer is deciding what to refactor.
|
||||||
|
- Wait for the developer's decision
|
||||||
|
- Options: audit (code smells), refactor tests, refactor implementation, next test, or custom
|
||||||
|
- After refactoring, run tests to verify they still pass
|
||||||
|
- Then call go-to-refactor to report back to the developer`;
|
||||||
|
}
|
||||||
|
}
|
||||||
297
src/test/state.test.ts
Normal file
297
src/test/state.test.ts
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
createInitialState,
|
||||||
|
redToGreen,
|
||||||
|
greenToRefactor,
|
||||||
|
applyRefactorDecision,
|
||||||
|
recordTestResult,
|
||||||
|
canTransition,
|
||||||
|
phaseEmoji,
|
||||||
|
phaseDescription,
|
||||||
|
phaseGuidelines,
|
||||||
|
type CycleState,
|
||||||
|
type RefactorDecision,
|
||||||
|
} from "../state.js";
|
||||||
|
|
||||||
|
describe("createInitialState", () => {
|
||||||
|
it("returns RED phase with defaults", () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
expect(state.phase).toBe("RED");
|
||||||
|
expect(state.testsRan).toBe(false);
|
||||||
|
expect(state.testsPassed).toBe(false);
|
||||||
|
expect(state.testsWritten).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redToGreen", () => {
|
||||||
|
it("transitions when tests ran and passed", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: true,
|
||||||
|
testsWritten: 1,
|
||||||
|
};
|
||||||
|
const result = redToGreen(state);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.state.phase).toBe("GREEN");
|
||||||
|
expect(result.state.testsRan).toBe(false);
|
||||||
|
expect(result.state.testsPassed).toBe(false);
|
||||||
|
expect(result.message).toContain("GREEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when tests have not been run", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: false,
|
||||||
|
testsPassed: false,
|
||||||
|
};
|
||||||
|
const result = redToGreen(state);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain("have not been run");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when tests failed", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: false,
|
||||||
|
};
|
||||||
|
const result = redToGreen(state);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain("did not pass");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("greenToRefactor", () => {
|
||||||
|
it("always transitions from GREEN to REFACTOR", () => {
|
||||||
|
const state: CycleState = { phase: "GREEN" };
|
||||||
|
const result = greenToRefactor(state);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.state.phase).toBe("REFACTOR");
|
||||||
|
expect(result.message).toContain("REFACTOR");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyRefactorDecision", () => {
|
||||||
|
it("stays in REFACTOR with tests focus", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR", testsWritten: 1 };
|
||||||
|
const decision: RefactorDecision = { focus: "tests" };
|
||||||
|
const result = applyRefactorDecision(state, decision);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.state.phase).toBe("REFACTOR");
|
||||||
|
expect(result.state.lastDecision).toEqual(decision);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays in REFACTOR with implementation focus", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR" };
|
||||||
|
const decision: RefactorDecision = { focus: "implementation" };
|
||||||
|
const result = applyRefactorDecision(state, decision);
|
||||||
|
expect(result.state.phase).toBe("REFACTOR");
|
||||||
|
expect(result.state.lastDecision).toEqual(decision);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays in REFACTOR with audit focus", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR" };
|
||||||
|
const decision: RefactorDecision = { focus: "audit" };
|
||||||
|
const result = applyRefactorDecision(state, decision);
|
||||||
|
expect(result.state.phase).toBe("REFACTOR");
|
||||||
|
expect(result.state.lastDecision).toEqual(decision);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to RED for next-test focus", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR", testsWritten: 3 };
|
||||||
|
const decision: RefactorDecision = { focus: "next-test" };
|
||||||
|
const result = applyRefactorDecision(state, decision);
|
||||||
|
expect(result.state.testsWritten).toBe(4);
|
||||||
|
expect(result.state.phase).toBe("RED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores custom instruction for custom focus", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR" };
|
||||||
|
const decision: RefactorDecision = {
|
||||||
|
focus: "custom",
|
||||||
|
customInstruction: "Extract the service class",
|
||||||
|
};
|
||||||
|
const result = applyRefactorDecision(state, decision);
|
||||||
|
expect(result.state.customRefactoringInstruction).toBe(
|
||||||
|
"Extract the service class",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets testsRan and testsPassed", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "REFACTOR",
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: true,
|
||||||
|
};
|
||||||
|
const decision: RefactorDecision = { focus: "implementation" };
|
||||||
|
const result = applyRefactorDecision(state, decision);
|
||||||
|
expect(result.state.testsRan).toBe(false);
|
||||||
|
expect(result.state.testsPassed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recordTestResult", () => {
|
||||||
|
it("records passing tests in RED → auto-transitions to GREEN", () => {
|
||||||
|
const state: CycleState = { phase: "RED", testsRan: false, testsPassed: false };
|
||||||
|
const result = recordTestResult(state, true);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.state.testsRan).toBe(true);
|
||||||
|
expect(result.state.testsPassed).toBe(true);
|
||||||
|
expect(result.state.phase).toBe("GREEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records failing tests in RED → stays in RED", () => {
|
||||||
|
const state: CycleState = { phase: "RED", testsRan: false, testsPassed: false };
|
||||||
|
const result = recordTestResult(state, false);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.state.testsRan).toBe(true);
|
||||||
|
expect(result.state.testsPassed).toBe(false);
|
||||||
|
expect(result.state.phase).toBe("RED");
|
||||||
|
expect(result.message).toContain("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records passing tests in GREEN → stays in GREEN", () => {
|
||||||
|
const state: CycleState = { phase: "GREEN", testsRan: true, testsPassed: true };
|
||||||
|
const result = recordTestResult(state, true);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.state.phase).toBe("GREEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects recording in REFACTOR phase", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR" };
|
||||||
|
const result = recordTestResult(state, true);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain("REFACTOR");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canTransition", () => {
|
||||||
|
it("allows RED → GREEN when tests passed", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: true,
|
||||||
|
};
|
||||||
|
const result = canTransition(state, "GREEN");
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks RED → GREEN when tests failed", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: false,
|
||||||
|
};
|
||||||
|
const result = canTransition(state, "GREEN");
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks RED → REFACTOR (must go through GREEN)", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: true,
|
||||||
|
};
|
||||||
|
const result = canTransition(state, "REFACTOR");
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows GREEN → REFACTOR", () => {
|
||||||
|
const state: CycleState = { phase: "GREEN" };
|
||||||
|
const result = canTransition(state, "REFACTOR");
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks GREEN → RED", () => {
|
||||||
|
const state: CycleState = { phase: "GREEN" };
|
||||||
|
const result = canTransition(state, "RED");
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows REFACTOR → RED", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR" };
|
||||||
|
const result = canTransition(state, "RED");
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks REFACTOR → GREEN", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR" };
|
||||||
|
const result = canTransition(state, "GREEN");
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phaseEmoji", () => {
|
||||||
|
it("returns correct emoji for each phase", () => {
|
||||||
|
expect(phaseEmoji("RED")).toBe("🔴");
|
||||||
|
expect(phaseEmoji("GREEN")).toBe("🟢");
|
||||||
|
expect(phaseEmoji("REFACTOR")).toBe("🔀");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phaseDescription", () => {
|
||||||
|
it("describes RED when no tests ran", () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
expect(phaseDescription(state)).toContain("RED");
|
||||||
|
expect(phaseDescription(state)).toContain("Write a failing test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes RED→GREEN when tests passed in RED", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: true,
|
||||||
|
};
|
||||||
|
expect(phaseDescription(state)).toContain("RED→GREEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes RED when tests failed", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: false,
|
||||||
|
};
|
||||||
|
expect(phaseDescription(state)).toContain("Tests failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phaseGuidelines", () => {
|
||||||
|
it("gives RED guidelines when no tests ran", () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
const guidelines = phaseGuidelines(state);
|
||||||
|
expect(guidelines).toContain("Write a failing test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives RED guidelines when tests failed", () => {
|
||||||
|
const state: CycleState = {
|
||||||
|
phase: "RED",
|
||||||
|
testsRan: true,
|
||||||
|
testsPassed: false,
|
||||||
|
};
|
||||||
|
const guidelines = phaseGuidelines(state);
|
||||||
|
expect(guidelines).toContain("Tests failed");
|
||||||
|
expect(guidelines).toContain("Do NOT change the test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives GREEN guidelines", () => {
|
||||||
|
const state: CycleState = { phase: "GREEN" };
|
||||||
|
const guidelines = phaseGuidelines(state);
|
||||||
|
expect(guidelines).toContain("GREEN");
|
||||||
|
expect(guidelines).toContain("go-to-refactor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives REFACTOR guidelines when deciding", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR" };
|
||||||
|
const guidelines = phaseGuidelines(state);
|
||||||
|
expect(guidelines).toContain("REFACTOR");
|
||||||
|
expect(guidelines).toContain("Wait for the developer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives REFACTOR guidelines when working on a decision", () => {
|
||||||
|
const state: CycleState = { phase: "REFACTOR", lastDecision: { focus: "implementation" } };
|
||||||
|
const guidelines = phaseGuidelines(state);
|
||||||
|
expect(guidelines).toContain("REFACTOR");
|
||||||
|
expect(guidelines).toContain("Execute the refactoring");
|
||||||
|
});
|
||||||
|
});
|
||||||
143
src/ui.ts
Normal file
143
src/ui.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Custom UI for the TDD cycle developer decision prompt.
|
||||||
|
*
|
||||||
|
* Renders an interactive menu during the REFACTOR phase where the developer
|
||||||
|
* can choose what to do next.
|
||||||
|
*
|
||||||
|
* Usage: pass the returned component to ctx.ui.custom().
|
||||||
|
* For custom text input, use ctx.ui.input() in the tool's execute function.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
matchesKey,
|
||||||
|
Key,
|
||||||
|
type TUI,
|
||||||
|
} from "@earendil-works/pi-tui";
|
||||||
|
import type { Theme, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { RefactorFocus, RefactorDecision } from "./state.js";
|
||||||
|
|
||||||
|
const REFACTOR_FOCI: RefactorFocus[] = [
|
||||||
|
"audit",
|
||||||
|
"tests",
|
||||||
|
"implementation",
|
||||||
|
"next-test",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FOCUS_LABELS: Record<RefactorFocus, string> = {
|
||||||
|
audit: "Identify code smells & verify minimality",
|
||||||
|
tests: "Refactor the tests",
|
||||||
|
implementation: "Refactor the implementation",
|
||||||
|
"next-test": "Continue to next test",
|
||||||
|
custom: "Something else (custom instruction)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FOCUS_DESCRIPTIONS: Record<RefactorFocus, string> = {
|
||||||
|
audit:
|
||||||
|
"Audit implementation: is all code needed? Is it minimal? Are there code smells?",
|
||||||
|
tests: "Clean up and improve test structure",
|
||||||
|
implementation: "Improve production code structure",
|
||||||
|
"next-test": "Write the next failing test",
|
||||||
|
custom: "Provide a custom instruction",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a custom UI component for the refactoring decision prompt.
|
||||||
|
* Call this from ctx.ui.custom() callback.
|
||||||
|
* For "custom" selection, the execute function should use ctx.ui.input() for the text.
|
||||||
|
*/
|
||||||
|
export function createRefactorDecisionComponent(
|
||||||
|
testDescription?: string,
|
||||||
|
): (
|
||||||
|
tui: TUI,
|
||||||
|
theme: Theme,
|
||||||
|
_kb: KeybindingsManager,
|
||||||
|
done: (decision: RefactorDecision | null) => void,
|
||||||
|
) => {
|
||||||
|
render: (width: number) => string[];
|
||||||
|
invalidate: () => void;
|
||||||
|
handleInput: (data: string) => boolean;
|
||||||
|
} {
|
||||||
|
let selectedIndex = 0;
|
||||||
|
|
||||||
|
return (tui, theme, _kb, done) => {
|
||||||
|
function render(width: number): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const w = Math.max(1, width - 2);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.push(theme.fg("accent", "─".repeat(w)));
|
||||||
|
lines.push(
|
||||||
|
theme.fg("accent", " 🔀 REFACTOR PHASE — What should we do next?"),
|
||||||
|
);
|
||||||
|
if (testDescription) {
|
||||||
|
lines.push(theme.fg("muted", ` Current test: ${testDescription}`));
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Options
|
||||||
|
for (let i = 0; i < REFACTOR_FOCI.length; i++) {
|
||||||
|
const focus = REFACTOR_FOCI[i];
|
||||||
|
const selected = i === selectedIndex;
|
||||||
|
const prefix = selected
|
||||||
|
? theme.fg("accent", "▸ ")
|
||||||
|
: " ";
|
||||||
|
const label = theme.fg(
|
||||||
|
selected ? "accent" : "text",
|
||||||
|
FOCUS_LABELS[focus],
|
||||||
|
);
|
||||||
|
lines.push(`${prefix}${label}`);
|
||||||
|
if (selected) {
|
||||||
|
lines.push(
|
||||||
|
` ${theme.fg("dim", FOCUS_DESCRIPTIONS[focus])}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push(
|
||||||
|
theme.fg(
|
||||||
|
"dim",
|
||||||
|
` ${theme.bold("↑↓")} navigate ${theme.bold("Enter")} select ${theme.bold("Esc")} cancel`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
lines.push(theme.fg("accent", "─".repeat(w)));
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(data: string): boolean {
|
||||||
|
if (matchesKey(data, Key.up)) {
|
||||||
|
selectedIndex = Math.max(0, selectedIndex - 1);
|
||||||
|
tui.requestRender();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.down)) {
|
||||||
|
selectedIndex = Math.min(
|
||||||
|
REFACTOR_FOCI.length - 1,
|
||||||
|
selectedIndex + 1,
|
||||||
|
);
|
||||||
|
tui.requestRender();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.enter)) {
|
||||||
|
const focus = REFACTOR_FOCI[selectedIndex];
|
||||||
|
// For "custom", return focus without instruction text;
|
||||||
|
// the execute function will use ctx.ui.input() to get the text.
|
||||||
|
done({ focus });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
done(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { render, invalidate: () => {}, handleInput };
|
||||||
|
};
|
||||||
|
}
|
||||||
4328
transcripts/code-smells.html
Normal file
4328
transcripts/code-smells.html
Normal file
File diff suppressed because one or more lines are too long
4328
transcripts/spike.html
Normal file
4328
transcripts/spike.html
Normal file
File diff suppressed because one or more lines are too long
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/test/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user