feat(coding-agent): added inline prompt and compound duration support to /loop
- Enabled inline prompt execution by allowing /loop to accept a trailing follow-up message. - Added support for compound duration formats (e.g., 1h30m) within limit specifications. - Updated logic to distinguish between limit tokens and prose to maintain backwards compatibility for unbounded loops. - Refactored loop argument parsing to return both duration/count limits and optional string prompts.
This commit is contained in:
@@ -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.
|
||||
@@ -1128,27 +1128,33 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.#cancelLoopAutoSubmit();
|
||||
}
|
||||
|
||||
async handleLoopCommand(args = ""): Promise<void> {
|
||||
async handleLoopCommand(args = ""): Promise<string | undefined> {
|
||||
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 {
|
||||
|
||||
@@ -38,55 +38,107 @@ const TIME_UNITS_MS = new Map<string, number>([
|
||||
["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(),
|
||||
|
||||
@@ -362,7 +362,7 @@ export interface InteractiveModeContext {
|
||||
handlePlanModeCommand(initialPrompt?: string): Promise<void>;
|
||||
handleGoalModeCommand(rest?: string): Promise<void>;
|
||||
handleGuidedGoalCommand(rest?: string): Promise<void>;
|
||||
handleLoopCommand(args?: string): Promise<void>;
|
||||
handleLoopCommand(args?: string): Promise<string | undefined>;
|
||||
disableLoopMode(): void;
|
||||
pauseLoop(): void;
|
||||
handlePlanApproval(details: PlanApprovalDetails): Promise<void>;
|
||||
|
||||
@@ -306,11 +306,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
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 };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 `<limit> <prompt>` 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);
|
||||
|
||||
Reference in New Issue
Block a user