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:
can1357
2026-06-19 23:50:06 +02:00
parent 1a92b3f854
commit 52843e4fb9
6 changed files with 178 additions and 69 deletions
+5 -1
View File
@@ -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 {
+89 -37
View File
@@ -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(),
+1 -1
View File
@@ -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 };
},
},
{
+63 -19
View File
@@ -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);