Skip to content

Commit 6012fdd

Browse files
wenshaoclaude
andcommitted
feat(loop): add nested slash commands, day unit, and natural language interval
- Nested slash commands: loop iterations now submit as string (not Part[]) so `/loop 5m /review` executes /review on each iteration. First iteration also goes through the callback+state drain path instead of submit_prompt, ensuring consistent behavior. - Day interval unit: parseInterval/formatInterval now support `d` (e.g., `/loop 1d check status`). MAX_INTERVAL_MS already covers 24h. - Natural language interval: `/loop check deploy every 5m` extracts the interval from the prompt tail (regex: `every <interval>$`). - Changed pendingLoopPrompt from ref to React state to trigger re-renders when the timer callback queues a prompt during Idle. - Added tests for day parsing/formatting (27 total). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 26fbe26 commit 6012fdd

4 files changed

Lines changed: 60 additions & 50 deletions

File tree

packages/cli/src/ui/AppContainer.tsx

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -945,15 +945,14 @@ export const AppContainer = (props: AppContainerProps) => {
945945
historyManagerRef.current = historyManager;
946946
const submitQueryRef = useRef(submitQuery);
947947
submitQueryRef.current = submitQuery;
948-
const streamingStateRef = useRef(streamingState);
949-
streamingStateRef.current = streamingState;
950948

951-
// Pending loop prompt waiting for Idle before submission (set when timer
952-
// fires during active streaming).
953-
const loopSubmitRef = useRef<Array<{ text: string }> | null>(null);
949+
// Pending loop prompt (string) waiting for Idle before submission.
950+
// Using state (not ref) so that setting it triggers a re-render and the
951+
// drain effect fires — even when streamingState is already Idle.
952+
const [pendingLoopPrompt, setPendingLoopPrompt] = useState<string | null>(
953+
null,
954+
);
954955
// Whether the current streaming response was initiated by the loop.
955-
// Set true when a loop prompt is submitted; cleared by handleFinalSubmit
956-
// (user-initiated) or when the completion effect processes the response.
957956
const loopInitiatedStreamRef = useRef(false);
958957

959958
useEffect(() => {
@@ -978,17 +977,10 @@ export const AppContainer = (props: AppContainerProps) => {
978977
},
979978
Date.now(),
980979
);
981-
// Submit as Part[] to bypass slash-command parsing (consistent with
982-
// the first-iteration submit_prompt path in loopCommand.ts).
983-
const parts: Array<{ text: string }> = [{ text: prompt }];
984-
if (streamingStateRef.current === StreamingState.Idle) {
985-
// Common path: timer fires while idle — submit immediately.
986-
loopInitiatedStreamRef.current = true;
987-
void submitQueryRef.current(parts);
988-
} else {
989-
// Edge case: timer fires during active streaming — queue for later.
990-
loopSubmitRef.current = parts;
991-
}
980+
// Queue the prompt as a string. Submitting as string (not Part[])
981+
// allows nested slash commands (e.g. /loop 5m /review) to be parsed
982+
// and executed on each iteration via isSlashCommand().
983+
setPendingLoopPrompt(prompt);
992984
});
993985
return () => {
994986
const lm = getLoopManager();
@@ -1012,13 +1004,9 @@ export const AppContainer = (props: AppContainerProps) => {
10121004
return;
10131005
}
10141006
// loopInitiatedStreamRef is true for iterations submitted via the
1015-
// callback (2nd+). For the FIRST iteration, submitted via the
1016-
// slash-command's submit_prompt return value, the ref is never set —
1017-
// but waitingForResponse is already true from manager.start(), so we
1018-
// accept it as a loop response when no queued prompt is pending.
1019-
if (!loopInitiatedStreamRef.current && loopSubmitRef.current !== null) {
1020-
// A queued prompt exists but hasn't been submitted yet — the stream
1021-
// that just finished is NOT from the loop (e.g. user's message).
1007+
// drain effect. When false but a pending prompt exists, the stream
1008+
// that just finished is NOT from the loop (e.g. user's message).
1009+
if (!loopInitiatedStreamRef.current && pendingLoopPrompt !== null) {
10221010
return;
10231011
}
10241012
loopInitiatedStreamRef.current = false;
@@ -1092,21 +1080,21 @@ export const AppContainer = (props: AppContainerProps) => {
10921080
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run on streamingState transitions
10931081
}, [streamingState]);
10941082

1095-
// Drain queued loop prompt when streaming becomes Idle.
1096-
// This only fires for the edge case where the timer fired during streaming.
1083+
// Drain pending loop prompt when streaming is Idle.
1084+
// Submits as string so nested slash commands (e.g. /review) are parsed.
10971085
useEffect(() => {
1098-
if (streamingState !== StreamingState.Idle || !loopSubmitRef.current) {
1086+
if (streamingState !== StreamingState.Idle || pendingLoopPrompt === null) {
10991087
return;
11001088
}
1101-
const parts = loopSubmitRef.current;
1102-
loopSubmitRef.current = null;
1089+
const prompt = pendingLoopPrompt;
1090+
setPendingLoopPrompt(null);
11031091
// If the loop was stopped while the prompt was queued, discard it.
11041092
if (!getLoopManager().isActive()) {
11051093
return;
11061094
}
11071095
loopInitiatedStreamRef.current = true;
1108-
void submitQueryRef.current(parts);
1109-
}, [streamingState]);
1096+
void submitQueryRef.current(prompt);
1097+
}, [streamingState, pendingLoopPrompt]);
11101098

11111099
const [idePromptAnswered, setIdePromptAnswered] = useState(false);
11121100
const [currentIDE, setCurrentIDE] = useState<IdeInfo | null>(null);

packages/cli/src/ui/commands/loopCommand.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,19 @@ function parseLoopArgs(args: string): {
8484
}
8585
}
8686

87-
const prompt = tokens.slice(startIndex).join(' ');
87+
let prompt = tokens.slice(startIndex).join(' ');
88+
89+
// Support natural language interval at the end: "check deploy every 5m"
90+
if (intervalMs === DEFAULT_INTERVAL_MS) {
91+
const everyMatch = prompt.match(/\s+every\s+(\d+(?:\.\d+)?[smhd])\s*$/i);
92+
if (everyMatch) {
93+
const parsed2 = parseInterval(everyMatch[1]);
94+
if (parsed2 !== null) {
95+
intervalMs = parsed2;
96+
prompt = prompt.slice(0, everyMatch.index).trim();
97+
}
98+
}
99+
}
88100

89101
return { intervalMs, maxIterations, prompt };
90102
}
@@ -239,27 +251,21 @@ export const loopCommand: SlashCommand = {
239251
Date.now(),
240252
);
241253

242-
// Start the loop — skipFirstIteration because we return submit_prompt below
243-
manager.start(
244-
{
245-
prompt: parsed.prompt,
246-
intervalMs: parsed.intervalMs,
247-
maxIterations: parsed.maxIterations,
248-
},
249-
true,
250-
);
251-
252-
// Submit the first iteration via the command system
253-
return {
254-
type: 'submit_prompt' as const,
255-
content: [{ text: parsed.prompt }],
256-
};
254+
// Start the loop — the iteration callback (registered in AppContainer)
255+
// will queue the first prompt via setPendingLoopPrompt, which triggers
256+
// a React state update → drain effect submits it as a string so that
257+
// nested slash commands (e.g. /loop 5m /review) are properly parsed.
258+
manager.start({
259+
prompt: parsed.prompt,
260+
intervalMs: parsed.intervalMs,
261+
maxIterations: parsed.maxIterations,
262+
});
257263
},
258264

259265
completion: async (_context, partialArg) => {
260266
const trimmed = partialArg.trim();
261267
if (!trimmed) {
262-
return ['status', 'stop', '5m ', '10m ', '30m ', '1h '];
268+
return ['status', 'stop', '5m ', '10m ', '30m ', '1h ', '1d '];
263269
}
264270
if ('status'.startsWith(trimmed)) {
265271
return ['status'];

packages/core/src/loop/loopManager.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ describe('parseInterval', () => {
2929
expect(parseInterval('2h')).toBe(7_200_000);
3030
});
3131

32+
it('parses days', () => {
33+
expect(parseInterval('1d')).toBe(86_400_000);
34+
expect(parseInterval('2d')).toBe(172_800_000);
35+
});
36+
3237
it('returns null for invalid input', () => {
3338
expect(parseInterval('')).toBeNull();
3439
expect(parseInterval('abc')).toBeNull();
@@ -42,10 +47,16 @@ describe('parseInterval', () => {
4247
expect(parseInterval('5M')).toBe(300_000);
4348
expect(parseInterval('1H')).toBe(3_600_000);
4449
expect(parseInterval('30S')).toBe(30_000);
50+
expect(parseInterval('1D')).toBe(86_400_000);
4551
});
4652
});
4753

4854
describe('formatInterval', () => {
55+
it('formats days', () => {
56+
expect(formatInterval(86_400_000)).toBe('1d');
57+
expect(formatInterval(172_800_000)).toBe('2d');
58+
});
59+
4960
it('formats hours', () => {
5061
expect(formatInterval(3_600_000)).toBe('1h');
5162
expect(formatInterval(7_200_000)).toBe('2h');

packages/core/src/loop/loopManager.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export type LoopIterationCallback = (prompt: string, iteration: number) => void;
6767
* Returns null if the string is not a valid interval.
6868
*/
6969
export function parseInterval(input: string): number | null {
70-
const match = input.match(/^(\d+(?:\.\d+)?)(s|m|h)$/i);
70+
const match = input.match(/^(\d+(?:\.\d+)?)(s|m|h|d)$/i);
7171
if (!match) return null;
7272

7373
const value = parseFloat(match[1]);
@@ -81,6 +81,8 @@ export function parseInterval(input: string): number | null {
8181
return Math.round(value * 60 * 1000);
8282
case 'h':
8383
return Math.round(value * 60 * 60 * 1000);
84+
case 'd':
85+
return Math.round(value * 24 * 60 * 60 * 1000);
8486
default:
8587
return null;
8688
}
@@ -91,6 +93,9 @@ export function parseInterval(input: string): number | null {
9193
* Prefers the largest unit that gives a clean representation.
9294
*/
9395
export function formatInterval(ms: number): string {
96+
if (ms >= 86_400_000 && ms % 86_400_000 === 0) {
97+
return `${ms / 86_400_000}d`;
98+
}
9499
if (ms >= 3600_000 && ms % 3600_000 === 0) {
95100
return `${ms / 3600_000}h`;
96101
}

0 commit comments

Comments
 (0)