add /loop-n command for repeated prompt execution
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
RiCloudLine,
|
||||
RiFoldersLine,
|
||||
RiGitBranchLine,
|
||||
RiMicLine,
|
||||
RiNotification3Line,
|
||||
RiPaletteLine,
|
||||
RiListUnordered,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user