From 816af87ac8365b66a9f4a8ab21fdacd3333cade9 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sat, 14 Mar 2026 17:28:55 +0800 Subject: [PATCH] add /loop-n command for repeated prompt execution --- ui/src/components/chat/ChatInput.tsx | 83 ++++++++++++++++++++++++ ui/src/components/chat/StatusRow.tsx | 24 ++++++- ui/src/components/layout/NavRail.tsx | 3 +- ui/src/components/views/SettingsView.tsx | 1 - ui/src/stores/messageStore.ts | 2 + 5 files changed, 109 insertions(+), 4 deletions(-) diff --git a/ui/src/components/chat/ChatInput.tsx b/ui/src/components/chat/ChatInput.tsx index b6a18b7..5607e07 100644 --- a/ui/src/components/chat/ChatInput.tsx +++ b/ui/src/components/chat/ChatInput.tsx @@ -161,6 +161,17 @@ export const ChatInput: React.FC = ({ 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 = ({ 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 = ({ 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'; diff --git a/ui/src/components/chat/StatusRow.tsx b/ui/src/components/chat/StatusRow.tsx index 60d130c..0e5ad6d 100644 --- a/ui/src/components/chat/StatusRow.tsx +++ b/ui/src/components/chat/StatusRow.tsx @@ -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 = ({ wasAborted, abortActive, retryInfo, + loopInfo, + onStopLoop, showAbort, onAbort, showAbortStatus, @@ -254,9 +259,26 @@ export const StatusRow: React.FC = ({ ) : null} - {/* Right: Abort (mobile only) + Todo */} + {/* Right: Abort (mobile only) + Todo + Loop indicator */}
{abortButton} + {loopInfo && ( +
+ + Loop {loopInfo.current}/{loopInfo.total} + + {onStopLoop && ( + + )} +
+ )} {todoTrigger} {/* Popover dropdown */} diff --git a/ui/src/components/layout/NavRail.tsx b/ui/src/components/layout/NavRail.tsx index fd06c31..0ed3536 100644 --- a/ui/src/components/layout/NavRail.tsx +++ b/ui/src/components/layout/NavRail.tsx @@ -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 = ({ className, mobile }) => { iconBackground?: string | null; } | null>(null); - const isDesktopApp = React.useMemo(() => isDesktopShell(), []); const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); const formatLabel = React.useCallback( diff --git a/ui/src/components/views/SettingsView.tsx b/ui/src/components/views/SettingsView.tsx index dce8829..b8d2362 100644 --- a/ui/src/components/views/SettingsView.tsx +++ b/ui/src/components/views/SettingsView.tsx @@ -20,7 +20,6 @@ import { RiCloudLine, RiFoldersLine, RiGitBranchLine, - RiMicLine, RiNotification3Line, RiPaletteLine, RiListUnordered, diff --git a/ui/src/stores/messageStore.ts b/ui/src/stores/messageStore.ts index b7157da..b34aa0e 100644 --- a/ui/src/stores/messageStore.ts +++ b/ui/src/stores/messageStore.ts @@ -709,6 +709,8 @@ export const useMessageStore = create()( 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();