diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 31cb43b79..7cea55254 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Added + +- Enabled inline prompts with `/loop` commands (e.g., `/loop 10 fix the bug`) +- Added support for compound duration formats in `/loop` (e.g., `1h30m`) ## [16.1.5] - 2026-06-19 @@ -12148,4 +12152,4 @@ Initial public release. ## [0.7.6] - 2025-11-13 -Previous releases did not maintain a changelog. +Previous releases did not maintain a changelog. \ No newline at end of file diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 776b45d1f..d632712c3 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1128,27 +1128,33 @@ export class InteractiveMode implements InteractiveModeContext { this.#cancelLoopAutoSubmit(); } - async handleLoopCommand(args = ""): Promise { + async handleLoopCommand(args = ""): Promise { if (this.loopModeEnabled) { this.disableLoopMode(); - return; + return undefined; } - const parsedLimit = parseLoopLimitArgs(args); - if (typeof parsedLimit === "string") { - this.showError(parsedLimit); - return; + const parsed = parseLoopLimitArgs(args); + if (typeof parsed === "string") { + this.showError(parsed); + return undefined; } this.loopModeEnabled = true; this.loopPrompt = undefined; - this.loopLimit = createLoopLimitRuntime(parsedLimit); + this.loopLimit = createLoopLimitRuntime(parsed.limit); this.statusLine.setLoopModeStatus({ enabled: true }); this.updateEditorTopBorder(); this.ui.requestRender(); - const limitSuffix = parsedLimit ? ` Limited to ${describeLoopLimit(parsedLimit)}.` : ""; + const limitSuffix = parsed.limit ? ` Limited to ${describeLoopLimit(parsed.limit)}.` : ""; const remainingSuffix = this.loopLimit ? ` ${describeLoopLimitRuntime(this.loopLimit)}.` : ""; + const tail = parsed.prompt ? "Repeating it after each turn." : "Your next prompt will repeat after each turn."; this.showStatus( - `Loop mode enabled.${limitSuffix}${remainingSuffix} Your next prompt will repeat after each turn. Esc cancels the current iteration; /loop again to disable.`, + `Loop mode enabled.${limitSuffix}${remainingSuffix} ${tail} Esc cancels the current iteration; /loop again to disable.`, ); + // Hand any inline prompt back to the dispatcher so the normal submit flow + // runs the first iteration — it records the text as the loop prompt and + // auto-resubmits it after each yield, identical to typing the prompt right + // after enabling loop mode. + return parsed.prompt; } recordLocalSubmission(text: string, imageCount = 0): () => void { diff --git a/packages/coding-agent/src/modes/loop-limit.ts b/packages/coding-agent/src/modes/loop-limit.ts index 289f73d5b..010a1c517 100644 --- a/packages/coding-agent/src/modes/loop-limit.ts +++ b/packages/coding-agent/src/modes/loop-limit.ts @@ -38,55 +38,107 @@ const TIME_UNITS_MS = new Map([ ["hours", 3_600_000], ]); -export function parseLoopLimitArgs(args: string): LoopLimitConfig | undefined | string { - const trimmed = args.trim().toLowerCase(); - if (!trimmed) return undefined; +const LOOP_USAGE = "Usage: /loop [count|duration]. Examples: /loop 10, /loop 10m, /loop 10min."; - const parts = trimmed.split(/\s+/); - if (parts.length > 2) { - return "Usage: /loop [count|duration]. Examples: /loop 10, /loop 10m, /loop 10min."; - } - - if (parts.length === 2) { - return parseDurationParts(parts[0], parts[1]); - } - - const token = parts[0]; - const iterationMatch = /^(\d+)$/.exec(token); - if (iterationMatch) { - const iterations = Number(iterationMatch[1]); - if (!Number.isSafeInteger(iterations) || iterations <= 0) { - return "Loop count must be a positive integer."; - } - return { kind: "iterations", iterations }; - } - - const durationMatch = /^(\d+)([a-z]+)$/.exec(token); - if (durationMatch) { - return parseDurationParts(durationMatch[1], durationMatch[2]); - } - - return "Usage: /loop [count|duration]. Examples: /loop 10, /loop 10m, /loop 10min."; +export interface ParsedLoopArgs { + /** Iteration/duration budget, when the user supplied a leading limit token. */ + limit?: LoopLimitConfig; + /** Inline loop prompt: text after the limit, or the whole argument when no limit was given. */ + prompt?: string; } -function parseDurationParts(amountText: string, unitText: string): LoopLimitConfig | string { - if (!/^\d+$/.test(amountText)) { - return "Loop duration must use a positive integer amount."; +/** + * Parse `/loop` arguments into an optional leading limit plus an optional inline + * prompt. A token that *looks* like a limit (starts with a digit or sign) but + * fails to parse is a hard error; anything else is treated as prompt text, so + * plain prose after `/loop` keeps starting an unbounded loop instead of erroring + * (the pre-arg-parsing behavior). Returns the error message string on failure. + */ +export function parseLoopLimitArgs(args: string): ParsedLoopArgs | string { + const trimmed = args.trim(); + if (!trimmed) return {}; + + const firstSpace = trimmed.search(/\s/); + const firstToken = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace); + const rest = firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1).trim(); + const token = firstToken.toLowerCase(); + + // Not a limit attempt (prose like "keep going") → unbounded loop, prompt = full args. + if (!/^[+-]?\d/.test(token)) { + return { prompt: trimmed }; } + // Bare integer: iteration count, unless the next token is a time unit ("10 minutes"). + if (/^\d+$/.test(token)) { + if (rest) { + const restTokens = rest.split(/\s+/); + const unitMs = TIME_UNITS_MS.get(restTokens[0].toLowerCase()); + if (unitMs !== undefined) { + const limit = makeDuration(token, unitMs); + if (typeof limit === "string") return limit; + return { limit, prompt: restTokens.slice(1).join(" ").trim() || undefined }; + } + } + const limit = makeIterations(token); + if (typeof limit === "string") return limit; + return { limit, prompt: rest || undefined }; + } + + // Compact / compound duration: "10m", "90s", "1h30m". + const duration = parseCompoundDuration(token); + if (duration !== undefined) { + if (typeof duration === "string") return duration; + return { limit: duration, prompt: rest || undefined }; + } + + // Limit-shaped but unparseable ("-1", "1.5h", "10x10"). + return LOOP_USAGE; +} + +function makeIterations(amountText: string): LoopLimitConfig | string { + const amount = Number(amountText); + if (!Number.isSafeInteger(amount) || amount <= 0) { + return "Loop count must be a positive integer."; + } + return { kind: "iterations", iterations: amount }; +} + +function makeDuration(amountText: string, unitMs: number): LoopLimitConfig | string { const amount = Number(amountText); if (!Number.isSafeInteger(amount) || amount <= 0) { return "Loop duration must be positive."; } - - const unitMs = TIME_UNITS_MS.get(unitText); - if (unitMs === undefined) { - return "Loop duration unit must be seconds, minutes, or hours."; - } - return { kind: "duration", durationMs: amount * unitMs }; } +/** + * Parse a compact duration token such as `10m`, or a compound one like `1h30m`. + * Returns `undefined` when the token is not duration-shaped, or an error string + * when it is shaped like a duration but uses an unknown unit / non-positive + * amount. + */ +function parseCompoundDuration(token: string): LoopLimitConfig | string | undefined { + if (!/^(?:\d+[a-z]+)+$/.test(token)) return undefined; + const segments = token.match(/\d+[a-z]+/g); + if (!segments) return undefined; + let totalMs = 0; + for (const segment of segments) { + const match = /^(\d+)([a-z]+)$/.exec(segment); + if (!match) return LOOP_USAGE; + const unitMs = TIME_UNITS_MS.get(match[2]); + if (unitMs === undefined) { + return "Loop duration unit must be seconds, minutes, or hours."; + } + const amount = Number(match[1]); + if (!Number.isSafeInteger(amount) || amount <= 0) { + return "Loop duration must be positive."; + } + totalMs += amount * unitMs; + } + if (totalMs <= 0) return "Loop duration must be positive."; + return { kind: "duration", durationMs: totalMs }; +} + export function createLoopLimitRuntime( config: LoopLimitConfig | undefined, nowMs = Date.now(), diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 22d137f1b..2af5e0514 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -362,7 +362,7 @@ export interface InteractiveModeContext { handlePlanModeCommand(initialPrompt?: string): Promise; handleGoalModeCommand(rest?: string): Promise; handleGuidedGoalCommand(rest?: string): Promise; - handleLoopCommand(args?: string): Promise; + handleLoopCommand(args?: string): Promise; disableLoopMode(): void; pauseLoop(): void; handlePlanApproval(details: PlanApprovalDetails): Promise; diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 074f6238e..1aff9ce11 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -306,11 +306,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ name: "loop", description: "Toggle loop mode. While enabled, the next prompt you send re-submits after every yield. Esc cancels the current iteration; /loop again to disable.", - inlineHint: "[count|duration]", + inlineHint: "[count|duration] [prompt]", allowArgs: true, handleTui: async (command, runtime) => { - await runtime.ctx.handleLoopCommand(command.args); + const prompt = await runtime.ctx.handleLoopCommand(command.args); runtime.ctx.editor.setText(""); + // Surface any inline prompt so the dispatcher returns it and the normal + // submit flow runs the first loop iteration (recording it as the loop prompt). + if (prompt) return { prompt }; }, }, { diff --git a/packages/coding-agent/test/loop-limit.test.ts b/packages/coding-agent/test/loop-limit.test.ts index 9e660785c..0334b9629 100644 --- a/packages/coding-agent/test/loop-limit.test.ts +++ b/packages/coding-agent/test/loop-limit.test.ts @@ -9,33 +9,77 @@ import type { BuiltinSlashCommandRuntime } from "@oh-my-pi/pi-coding-agent/slash import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; describe("/loop slash command", () => { - test("accepts an optional limit argument", async () => { - const handleLoopCommand = vi.fn(async (_args?: string) => {}); + test("forwards a bare limit argument verbatim", async () => { + const handleLoopCommand = vi.fn(async (_args?: string) => undefined); const runtime = { - ctx: { - handleLoopCommand, - editor: { setText: vi.fn() }, - }, + ctx: { handleLoopCommand, editor: { setText: vi.fn() } }, } as unknown as BuiltinSlashCommandRuntime; const result = await executeBuiltinSlashCommand("/loop 10min", runtime); expect(result).toBe(true); expect(handleLoopCommand).toHaveBeenCalledWith("10min"); }); + + test("forwards the full residual and propagates the inline prompt for submission", async () => { + // The dispatcher must hand the entire ` ` string to + // handleLoopCommand (the parser, not the dispatcher, splits limit vs prompt) + // and surface the returned inline prompt so input-controller submits it. + const handleLoopCommand = vi.fn(async (_args?: string) => "fix the failing tests"); + const setText = vi.fn(); + const runtime = { + ctx: { handleLoopCommand, editor: { setText } }, + } as unknown as BuiltinSlashCommandRuntime; + const result = await executeBuiltinSlashCommand("/loop 10m fix the failing tests", runtime); + + expect(handleLoopCommand).toHaveBeenCalledWith("10m fix the failing tests"); + expect(result).toBe("fix the failing tests"); + expect(setText).toHaveBeenCalledWith(""); + }); }); describe("loop limit parsing", () => { + test("empty args produce neither a limit nor a prompt", () => { + expect(parseLoopLimitArgs("")).toEqual({}); + expect(parseLoopLimitArgs(" ")).toEqual({}); + }); + test("parses a bare positive integer as an iteration limit", () => { - expect(parseLoopLimitArgs("10")).toEqual({ kind: "iterations", iterations: 10 }); + expect(parseLoopLimitArgs("10")).toEqual({ limit: { kind: "iterations", iterations: 10 } }); }); test("parses minute duration aliases", () => { - expect(parseLoopLimitArgs("10m")).toEqual({ kind: "duration", durationMs: 600_000 }); - expect(parseLoopLimitArgs("10min")).toEqual({ kind: "duration", durationMs: 600_000 }); - expect(parseLoopLimitArgs("10 minutes")).toEqual({ kind: "duration", durationMs: 600_000 }); + expect(parseLoopLimitArgs("10m")).toEqual({ limit: { kind: "duration", durationMs: 600_000 } }); + expect(parseLoopLimitArgs("10min")).toEqual({ limit: { kind: "duration", durationMs: 600_000 } }); + expect(parseLoopLimitArgs("10 minutes")).toEqual({ limit: { kind: "duration", durationMs: 600_000 } }); }); - test("rejects zero, negative, and unknown limits", () => { + test("parses compound durations like 1h30m", () => { + expect(parseLoopLimitArgs("1h30m")).toEqual({ limit: { kind: "duration", durationMs: 5_400_000 } }); + expect(parseLoopLimitArgs("2h30min")).toEqual({ limit: { kind: "duration", durationMs: 9_000_000 } }); + }); + + test("treats trailing text after a valid limit as an inline prompt", () => { + expect(parseLoopLimitArgs("10m keep refactoring")).toEqual({ + limit: { kind: "duration", durationMs: 600_000 }, + prompt: "keep refactoring", + }); + expect(parseLoopLimitArgs("5 fix the bug")).toEqual({ + limit: { kind: "iterations", iterations: 5 }, + prompt: "fix the bug", + }); + // Space-separated unit must win over treating the count as bare iterations. + expect(parseLoopLimitArgs("10 minutes keep going")).toEqual({ + limit: { kind: "duration", durationMs: 600_000 }, + prompt: "keep going", + }); + }); + + test("treats non-limit prose as an unbounded loop with an inline prompt", () => { + expect(parseLoopLimitArgs("keep going")).toEqual({ prompt: "keep going" }); + expect(parseLoopLimitArgs("fix the failing tests")).toEqual({ prompt: "fix the failing tests" }); + }); + + test("rejects zero, negative, and unknown limit-shaped tokens", () => { expect(parseLoopLimitArgs("0")).toBe("Loop count must be a positive integer."); expect(parseLoopLimitArgs("-1")).toContain("Usage: /loop"); expect(parseLoopLimitArgs("10fortnights")).toBe("Loop duration unit must be seconds, minutes, or hours."); @@ -44,11 +88,11 @@ describe("loop limit parsing", () => { describe("loop limit runtime", () => { test("allows exactly the configured number of auto-submitted iterations", () => { - const config = parseLoopLimitArgs("3"); - expect(config).toEqual({ kind: "iterations", iterations: 3 }); - if (!config || typeof config === "string") throw new Error("expected parsed config"); + const parsed = parseLoopLimitArgs("3"); + if (typeof parsed === "string" || !parsed.limit) throw new Error("expected parsed limit"); + expect(parsed.limit).toEqual({ kind: "iterations", iterations: 3 }); - const limit = createLoopLimitRuntime(config); + const limit = createLoopLimitRuntime(parsed.limit); expect(consumeLoopLimitIteration(limit)).toBe(true); expect(consumeLoopLimitIteration(limit)).toBe(true); expect(consumeLoopLimitIteration(limit)).toBe(true); @@ -57,11 +101,11 @@ describe("loop limit runtime", () => { }); test("stops duration-limited loops at the configured deadline", () => { - const config = parseLoopLimitArgs("10m"); - expect(config).toEqual({ kind: "duration", durationMs: 600_000 }); - if (!config || typeof config === "string") throw new Error("expected parsed config"); + const parsed = parseLoopLimitArgs("10m"); + if (typeof parsed === "string" || !parsed.limit) throw new Error("expected parsed limit"); + expect(parsed.limit).toEqual({ kind: "duration", durationMs: 600_000 }); - const limit = createLoopLimitRuntime(config, 1_000); + const limit = createLoopLimitRuntime(parsed.limit, 1_000); expect(consumeLoopLimitIteration(limit, 600_999)).toBe(true); expect(isLoopDurationExpired(limit, 600_999)).toBe(false); expect(consumeLoopLimitIteration(limit, 601_000)).toBe(false);