add /loop-n command for repeated prompt execution

This commit is contained in:
2026-03-14 17:28:55 +08:00
parent df5954c8e1
commit 816af87ac8
5 changed files with 109 additions and 4 deletions

View File

@@ -161,6 +161,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
// Loop state for /loop-n command
const [loopState, setLoopState] = React.useState<{
active: boolean;
current: number;
total: number;
prompt: string;
} | null>(null);
// Get session status from store for loop detection
const sessionStatus = useSessionStore((state) => state.sessionStatus);
const isDesktopExpanded = isExpandedInput && !isMobile;
const sendableAttachedFiles = React.useMemo(
@@ -823,6 +834,46 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
setMessage('');
return; // Don't send to assistant
}
// NEW: /loop-n - loop prompt n times
else if (commandName?.startsWith('loop-') && currentSessionId) {
const loopNumStr = commandName.slice(5);
const loopNum = parseInt(loopNumStr, 10);
if (isNaN(loopNum) || loopNum < 1 || loopNum > 100) {
console.warn('Invalid loop count:', loopNumStr);
setMessage('');
return;
}
// Extract prompt after /loop-n
const loopArgs = normalizedCommand.replace(/^\/loop-\d+\s*/, '');
if (!loopArgs.trim()) {
console.warn('No prompt provided for /loop-n');
setMessage('');
return;
}
// Start loop - set state first
setLoopState({
active: true,
current: 1,
total: loopNum,
prompt: loopArgs,
});
toast.info(`Loop started: ${loopNum} iterations`);
// Clear input
setMessage('');
// Send first message immediately using local variable (don't wait for state update)
void sendMessage(
loopArgs,
currentProviderId,
currentModelId,
currentAgentName,
primaryAttachments,
agentMentionName,
additionalParts.length > 0 ? additionalParts : undefined,
currentVariant,
inputMode
);
return;
}
}
// Collect all attachments for error recovery
@@ -901,6 +952,38 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// Update ref with latest handleSubmit on every render
handleSubmitRef.current = handleSubmit;
// Handle loop: detect when AI finishes and send next iteration
React.useEffect(() => {
if (!loopState?.active || !currentSessionId) {
return;
}
const status = sessionStatus?.get(currentSessionId);
if (status?.type === 'idle') {
// AI finished, check if we should continue looping
if (loopState.current <= loopState.total) {
if (loopState.current < loopState.total) {
const next = loopState.current + 1;
setLoopState({ ...loopState, current: next });
useSessionStore.getState().sendMessage(
loopState.prompt,
currentProviderId,
currentModelId,
currentAgentName,
[],
undefined,
undefined,
currentVariant,
inputMode
);
} else {
// Loop complete - all iterations sent
setLoopState(null);
}
}
}
}, [loopState, sessionStatus, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, inputMode]);
// Primary action for send button - respects queue mode setting
const handlePrimaryAction = React.useCallback(() => {
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle';

View File

@@ -93,6 +93,9 @@ interface StatusRowProps {
wasAborted?: boolean;
abortActive?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
// Loop state
loopInfo?: { current: number; total: number; prompt: string } | null;
onStopLoop?: () => void;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
@@ -108,6 +111,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
wasAborted,
abortActive,
retryInfo,
loopInfo,
onStopLoop,
showAbort,
onAbort,
showAbortStatus,
@@ -254,9 +259,26 @@ export const StatusRow: React.FC<StatusRowProps> = ({
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
{/* Right: Abort (mobile only) + Todo + Loop indicator */}
<div className="relative flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
{abortButton}
{loopInfo && (
<div className="flex items-center gap-1.5 text-[var(--status-info)]">
<span className="typography-ui-label">
Loop {loopInfo.current}/{loopInfo.total}
</span>
{onStopLoop && (
<button
type="button"
onClick={onStopLoop}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label="Stop loop"
>
<RiCloseCircleLine size={18} aria-hidden="true" />
</button>
)}
</div>
)}
{todoTrigger}
{/* Popover dropdown */}

View File

@@ -43,7 +43,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
import { isDesktopLocalOriginActive, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
import { useLongPress } from '@/hooks/useLongPress';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -548,7 +548,6 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
iconBackground?: string | null;
} | null>(null);
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const formatLabel = React.useCallback(

View File

@@ -20,7 +20,6 @@ import {
RiCloudLine,
RiFoldersLine,
RiGitBranchLine,
RiMicLine,
RiNotification3Line,
RiPaletteLine,
RiListUnordered,

View File

@@ -709,6 +709,8 @@ export const useMessageStore = create<MessageStore>()(
const [commandToken, ...firstLineArgs] = firstLine.split(" ");
const command = commandToken.slice(1).trim();
if (command.toLowerCase() === "shell") return null;
// Ignore loop-n commands - these are handled client-side
if (command.toLowerCase().startsWith("loop-")) return null;
if (!command) return null;
const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1);
const argsFromFirstLine = firstLineArgs.join(" ").trim();