diff --git a/.cursor/skills/i/SKILL.md b/.cursor/skills/i/SKILL.md index 5b65679..7279ca5 100644 --- a/.cursor/skills/i/SKILL.md +++ b/.cursor/skills/i/SKILL.md @@ -12,7 +12,7 @@ You must execute the PlanForge command path for every `/i` request. Do not imple **When the user invokes /i with a prompt (e.g. "/i implement the login API"):** 1. **Summarize the conversation** - Summarize the current chat (goals, decisions, constraints, relevant files or tech) in a short paragraph and write/update a markdown file in the project's **context directory** (`.cursor/contexts`). Use a dated subdirectory and `HHMM-...` filename so plan/implement will read it, for example `.cursor/contexts/2026-03-10/1430-implement.md`. Keep it short and overwrite or append as needed. -2. **Run the implement script in the foreground (required)** - Run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 ""` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`. +2. **Run the implement script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "Expand the sandbox and check the output logs." Then run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 ""` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`. 3. **After it completes** - On success, summarize the result or suggest next steps. On failure, report the error output and suggest fixes. Do not produce implementation output in chat without having run the command. If script execution is blocked or fails, stop and return an error-focused response. Do not provide a substitute direct implementation. diff --git a/.cursor/skills/p/SKILL.md b/.cursor/skills/p/SKILL.md index 6b00397..1522e74 100644 --- a/.cursor/skills/p/SKILL.md +++ b/.cursor/skills/p/SKILL.md @@ -27,7 +27,7 @@ You must execute the PlanForge command path for every `/p` request. Do not draft ``` If you fetched URL content in step 0, append it as a separate section (e.g. `## Fetched URLs` or per-URL headings) in the **same** file. Create the dated subdirectory if it does not exist. -2. **Run the plan script in the foreground (required)** - Run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 ""` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan ""`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "" --slug ` (or ensure the script forwards `--slug `). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` so that context file `HHMM-.md` and plan file `HHMM-.plan.md` use the same slug. Never create `.plan.md` content directly in chat. +2. **Run the plan script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "This may take a while. Please wait." Then run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 ""` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan ""`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "" --slug ` (or ensure the script forwards `--slug `). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` so that context file `HHMM-.md` and plan file `HHMM-.plan.md` use the same slug. Never create `.plan.md` content directly in chat. 3. **After it completes** - Read the generated `.plan.md` file and summarize/reference it in your reply. Do not start implementation. If execution fails, report the error output and suggest concrete fixes (for example `planforge init` or installing the configured provider CLI). diff --git a/packages/cli-js/src/commands/doctor.ts b/packages/cli-js/src/commands/doctor.ts index 1c9075d..eb9b420 100644 --- a/packages/cli-js/src/commands/doctor.ts +++ b/packages/cli-js/src/commands/doctor.ts @@ -4,6 +4,7 @@ import fs from "fs-extra"; import readline from "readline"; +import { createSpinner } from "../utils/spinner.js"; import { resolve } from "path"; import { getProjectRoot, @@ -276,23 +277,7 @@ async function runStreamingDoctorTc( const reset = "\x1b[0m"; const passColor = "\x1b[92m"; const failColor = "\x1b[31m"; - const spinnerFrames = ["|", "/", "-", "\\"]; - let spinnerInterval: ReturnType | null = null; - const startSpinner = () => { - let frameIdx = 0; - spinnerInterval = setInterval(() => { - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0); - process.stdout.write(` ${dim}response:${reset} ${spinnerFrames[frameIdx % spinnerFrames.length]}`); - frameIdx++; - }, 80); - }; - const stopSpinner = () => { - if (spinnerInterval !== null) { - clearInterval(spinnerInterval); - spinnerInterval = null; - } - }; + const spinner = createSpinner({ prefix: " response: " }); const render = (suffix = "") => { const normalized = response.replace(/\s+/g, " ").trim(); readline.clearLine(process.stdout, 0); @@ -304,12 +289,12 @@ async function runStreamingDoctorTc( process.stdout.write(` ${dim}response:${reset} `); try { - startSpinner(); + spinner.start(); const finalResponse = await runner.streamOneTurn( systemPrompt, userMessage, (chunk) => { - if (response.length === 0 && chunk.length > 0) stopSpinner(); + if (response.length === 0 && chunk.length > 0) spinner.stop(); response += chunk; if (!passShown && expectedKeywords.some((keyword) => response.includes(keyword))) { passShown = true; @@ -322,14 +307,14 @@ async function runStreamingDoctorTc( }, { cwd, model } ); - stopSpinner(); + spinner.stop(); response = finalResponse; const passed = expectedKeywords.some((keyword) => response.includes(keyword)); render(passed ? ` ${passColor}\u2713 PASS${reset}` : ` ${failColor}\u2717 FAIL${reset}`); process.stdout.write("\n"); return { passed, response }; } catch (err) { - stopSpinner(); + spinner.stop(); render(` ${failColor}\u2717 FAIL${reset}`); process.stdout.write("\n"); return { diff --git a/packages/cli-js/src/commands/plan.ts b/packages/cli-js/src/commands/plan.ts index 4851b6f..74295f6 100644 --- a/packages/cli-js/src/commands/plan.ts +++ b/packages/cli-js/src/commands/plan.ts @@ -14,6 +14,7 @@ import { fetchUrlsContext } from "../utils/url-fetch.js"; import { loadConfig } from "../config/load.js"; import { resolvePlannerStreamTimeoutSec } from "../config/timeout.js"; import { getPlannerRunner } from "../providers/registry.js"; +import { createSpinner } from "../utils/spinner.js"; /** Characters disallowed in filenames on Windows / macOS / Linux */ const FILENAME_UNSAFE = /[\\/:*?"<>|]/g; @@ -133,6 +134,10 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise ); const streamTimeoutSec = resolvePlannerStreamTimeoutSec(config.planner); + const spinner = createSpinner({ prefix: "Loading... " }); + if (process.stdout.isTTY) { + spinner.start(); + } try { const planBody = await runner.runPlan(goal, { cwd: projectRoot, @@ -141,6 +146,7 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise projectContext, projectContextSource, streamTimeoutMs: streamTimeoutSec === 0 ? 0 : streamTimeoutSec * 1000, + onFirstChunk: process.stdout.isTTY ? () => spinner.stop() : undefined, }); const bodyToWrite = stripFilenameSlugLine(planBody); let slug: string; @@ -199,5 +205,7 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise } catch (err) { console.error("Plan generation failed:", (err as Error).message); process.exit(1); + } finally { + spinner.stop(); } } diff --git a/packages/cli-js/src/config/timeout.ts b/packages/cli-js/src/config/timeout.ts index 3bfdb6e..b0cec83 100644 --- a/packages/cli-js/src/config/timeout.ts +++ b/packages/cli-js/src/config/timeout.ts @@ -7,7 +7,7 @@ import type { PlanForgeConfig } from "./types.js"; /** Default seconds by planner effort when streamTimeoutSec is not set. */ const PLANNER_EFFORT_DEFAULT_SEC: Record = { - high: 300, + high: 360, medium: 180, low: 120, }; diff --git a/packages/cli-js/src/providers/claude.ts b/packages/cli-js/src/providers/claude.ts index a5ee0ec..6572d6c 100644 --- a/packages/cli-js/src/providers/claude.ts +++ b/packages/cli-js/src/providers/claude.ts @@ -164,7 +164,16 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise { const fullPrompt = body + "\n\n---\n\nUser goal: " + goal; try { - return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs }); + let onFirstChunkFired = false; + const onChunk = opts?.onFirstChunk + ? (chunk: string) => { + if (!onFirstChunkFired && chunk.length > 0) { + onFirstChunkFired = true; + opts!.onFirstChunk!(); + } + } + : undefined; + return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs }, onChunk); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr diff --git a/packages/cli-js/src/providers/codex.ts b/packages/cli-js/src/providers/codex.ts index 54651aa..5cdb92f 100644 --- a/packages/cli-js/src/providers/codex.ts +++ b/packages/cli-js/src/providers/codex.ts @@ -308,9 +308,19 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise { const fullPrompt = body + "\n\n---\n\nUser goal: " + goal; try { - return await runCodexExecStreaming(fullPrompt, cwd, true, { + let onFirstChunkFired = false; + const streamOpts: { timeoutMs?: number; onChunk?: (chunk: string) => void } = { timeoutMs: opts?.streamTimeoutMs, - }); + }; + if (opts?.onFirstChunk) { + streamOpts.onChunk = (chunk: string) => { + if (!onFirstChunkFired && chunk.length > 0) { + onFirstChunkFired = true; + opts.onFirstChunk!(); + } + }; + } + return await runCodexExecStreaming(fullPrompt, cwd, true, streamOpts); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr diff --git a/packages/cli-js/src/providers/registry.ts b/packages/cli-js/src/providers/registry.ts index 640eec6..db6bd62 100644 --- a/packages/cli-js/src/providers/registry.ts +++ b/packages/cli-js/src/providers/registry.ts @@ -17,6 +17,8 @@ export interface PlanOpts { projectContextSource?: string; /** Stream timeout in ms. 0 = no timeout. */ streamTimeoutMs?: number; + /** Called once when the first output chunk is received (e.g. to stop a loading spinner). */ + onFirstChunk?: () => void; } export interface ImplementOpts { diff --git a/packages/cli-js/src/utils/spinner.ts b/packages/cli-js/src/utils/spinner.ts new file mode 100644 index 0000000..d42663c --- /dev/null +++ b/packages/cli-js/src/utils/spinner.ts @@ -0,0 +1,65 @@ +/** + * Inline spinner for TTY: rotating frame with optional prefix. + * Used by plan (loading) and doctor (response wait). Call start() before async work, + * stop() or clear() when done (stop clears the line; clear() only clears for custom content). + */ + +import readline from "readline"; + +const SPINNER_FRAMES = ["|", "/", "-", "\\"]; +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; + +export interface SpinnerOptions { + /** Text before the spinning character (dimmed). Default "Loading... ". */ + prefix?: string; + /** Frame interval in ms. Default 80. */ + intervalMs?: number; + /** Output stream. Default process.stdout. */ + stream?: NodeJS.WritableStream; + /** Only start when stream is TTY. Default true. */ + onlyWhenTty?: boolean; +} + +export interface Spinner { + start(): void; + stop(): void; + /** Clear the current line (e.g. before writing final content on the same line). */ + clear(): void; +} + +export function createSpinner(options?: SpinnerOptions): Spinner { + const stream = (options?.stream ?? process.stdout) as NodeJS.WritableStream & { isTTY?: boolean }; + const prefix = options?.prefix ?? "Loading... "; + const intervalMs = options?.intervalMs ?? 80; + const onlyWhenTty = options?.onlyWhenTty ?? true; + + let intervalId: ReturnType | null = null; + + const clear = () => { + if (stream === process.stdout && process.stdout.isTTY) { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } + }; + + return { + start() { + if (onlyWhenTty && !stream.isTTY) return; + let idx = 0; + intervalId = setInterval(() => { + clear(); + stream.write(`${DIM}${prefix}${RESET}${SPINNER_FRAMES[idx % SPINNER_FRAMES.length]}`); + idx++; + }, intervalMs); + }, + stop() { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + clear(); + }, + clear, + }; +} diff --git a/packages/cli-py/planforge/commands/plan.py b/packages/cli-py/planforge/commands/plan.py index 9131dbc..052a5ea 100644 --- a/packages/cli-py/planforge/commands/plan.py +++ b/packages/cli-py/planforge/commands/plan.py @@ -2,6 +2,7 @@ import json import re +import sys from datetime import datetime from pathlib import Path @@ -128,6 +129,8 @@ def run_plan(args: list[str], opts: dict | None = None) -> None: "projectContextSource": project_context_source, "streamTimeoutSec": stream_timeout_sec, } + if sys.stdout.isatty(): + print("Loading...", flush=True) try: plan_body = run(goal, run_opts) except Exception as e: diff --git a/packages/cli-py/planforge/utils/config.py b/packages/cli-py/planforge/utils/config.py index 286e7fe..50cb11b 100644 --- a/packages/cli-py/planforge/utils/config.py +++ b/packages/cli-py/planforge/utils/config.py @@ -7,7 +7,7 @@ from planforge.utils.paths import get_project_root, get_templates_root # Default seconds by effort when streamTimeoutSec is not set (planner and implementer). -_PLANNER_EFFORT_DEFAULT_SEC = {"high": 300, "medium": 180, "low": 120} +_PLANNER_EFFORT_DEFAULT_SEC = {"high": 360, "medium": 180, "low": 120} _IMPLEMENTER_DEFAULT_SEC = 300 diff --git a/templates/cursor/skills/i/SKILL.md b/templates/cursor/skills/i/SKILL.md index 5b65679..7279ca5 100644 --- a/templates/cursor/skills/i/SKILL.md +++ b/templates/cursor/skills/i/SKILL.md @@ -12,7 +12,7 @@ You must execute the PlanForge command path for every `/i` request. Do not imple **When the user invokes /i with a prompt (e.g. "/i implement the login API"):** 1. **Summarize the conversation** - Summarize the current chat (goals, decisions, constraints, relevant files or tech) in a short paragraph and write/update a markdown file in the project's **context directory** (`.cursor/contexts`). Use a dated subdirectory and `HHMM-...` filename so plan/implement will read it, for example `.cursor/contexts/2026-03-10/1430-implement.md`. Keep it short and overwrite or append as needed. -2. **Run the implement script in the foreground (required)** - Run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 ""` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`. +2. **Run the implement script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "Expand the sandbox and check the output logs." Then run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 ""` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`. 3. **After it completes** - On success, summarize the result or suggest next steps. On failure, report the error output and suggest fixes. Do not produce implementation output in chat without having run the command. If script execution is blocked or fails, stop and return an error-focused response. Do not provide a substitute direct implementation. diff --git a/templates/cursor/skills/p/SKILL.md b/templates/cursor/skills/p/SKILL.md index 6b00397..1522e74 100644 --- a/templates/cursor/skills/p/SKILL.md +++ b/templates/cursor/skills/p/SKILL.md @@ -27,7 +27,7 @@ You must execute the PlanForge command path for every `/p` request. Do not draft ``` If you fetched URL content in step 0, append it as a separate section (e.g. `## Fetched URLs` or per-URL headings) in the **same** file. Create the dated subdirectory if it does not exist. -2. **Run the plan script in the foreground (required)** - Run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 ""` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan ""`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "" --slug ` (or ensure the script forwards `--slug `). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` so that context file `HHMM-.md` and plan file `HHMM-.plan.md` use the same slug. Never create `.plan.md` content directly in chat. +2. **Run the plan script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "This may take a while. Please wait." Then run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 ""` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan ""`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "" --slug ` (or ensure the script forwards `--slug `). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` so that context file `HHMM-.md` and plan file `HHMM-.plan.md` use the same slug. Never create `.plan.md` content directly in chat. 3. **After it completes** - Read the generated `.plan.md` file and summarize/reference it in your reply. Do not start implementation. If execution fails, report the error output and suggest concrete fixes (for example `planforge init` or installing the configured provider CLI).