diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 15d31c7323b0..9d376b960e07 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -822,13 +822,14 @@ function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, skillMetadata: ReadonlyMap, + includeSkillTokens: boolean, ): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); - const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); + const segments = splitPromptIntoComposerSegments(prompt, terminalContexts, includeSkillTokens); for (const segment of segments) { if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path)); @@ -882,6 +883,7 @@ interface ComposerPromptEditorProps { cursor: number; terminalContexts: ReadonlyArray; skills: ReadonlyArray; + includeSkillTokens?: boolean; disabled: boolean; placeholder: string; className?: string; @@ -963,7 +965,7 @@ function ComposerCommandKeyPlugin(props: { return null; } -function ComposerInlineTokenArrowPlugin() { +function ComposerInlineTokenArrowPlugin(props: { includeSkillTokens: boolean }) { const [editor] = useLexicalComposerContext(); useEffect(() => { @@ -977,7 +979,14 @@ function ComposerInlineTokenArrowPlugin() { const currentOffset = $readSelectionOffsetFromEditorState(0); if (currentOffset <= 0) return; const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { + if ( + !isCollapsedCursorAdjacentToInlineToken( + promptValue, + currentOffset, + "left", + props.includeSkillTokens, + ) + ) { return; } nextOffset = currentOffset - 1; @@ -1004,7 +1013,14 @@ function ComposerInlineTokenArrowPlugin() { const composerLength = $getComposerRootLength(); if (currentOffset >= composerLength) return; const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { + if ( + !isCollapsedCursorAdjacentToInlineToken( + promptValue, + currentOffset, + "right", + props.includeSkillTokens, + ) + ) { return; } nextOffset = currentOffset + 1; @@ -1024,7 +1040,7 @@ function ComposerInlineTokenArrowPlugin() { unregisterLeft(); unregisterRight(); }; - }, [editor]); + }, [editor, props.includeSkillTokens]); return null; } @@ -1263,6 +1279,7 @@ function ComposerInlineTokenPastePlugin() { function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; skills: ReadonlyArray; + includeSkillTokens: boolean; }) { const [editor] = useLexicalComposerContext(); const terminalContextsRef = useRef(props.terminalContexts); @@ -1330,10 +1347,16 @@ function ComposerSurroundSelectionPlugin(props: { selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; - $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); + $setComposerEditorPrompt( + nextValue, + terminalContextsRef.current, + skillMetadataRef.current, + props.includeSkillTokens, + ); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, + props.includeSkillTokens, ); $setSelectionRangeAtComposerOffsets( selectionStart + inputData.length, @@ -1464,6 +1487,7 @@ function ComposerSurroundSelectionPlugin(props: { const replacementStart = collapseExpandedComposerCursor( currentValue, pendingDeadKeySelection.expandedStart, + props.includeSkillTokens, ); $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); const replacementSelection = $getSelection(); @@ -1531,6 +1555,7 @@ function ComposerPromptEditorInner({ cursor, terminalContexts, skills, + includeSkillTokens = true, disabled, placeholder, className, @@ -1542,16 +1567,17 @@ function ComposerPromptEditorInner({ }: ComposerPromptEditorProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); - const initialCursor = clampCollapsedComposerCursor(value, cursor); + const initialCursor = clampCollapsedComposerCursor(value, cursor, includeSkillTokens); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); const skillsSignature = skillSignature(skills); const skillsSignatureRef = useRef(skillsSignature); + const includeSkillTokensRef = useRef(includeSkillTokens); const skillMetadataRef = useRef(skillMetadataByName(skills)); const snapshotRef = useRef({ value, cursor: initialCursor, - expandedCursor: expandCollapsedComposerCursor(value, initialCursor), + expandedCursor: expandCollapsedComposerCursor(value, initialCursor, includeSkillTokens), terminalContextIds: terminalContexts.map((context) => context.id), }); const isApplyingControlledUpdateRef = useRef(false); @@ -1573,15 +1599,17 @@ function ComposerPromptEditorInner({ }, [disabled, editor]); useLayoutEffect(() => { - const normalizedCursor = clampCollapsedComposerCursor(value, cursor); + const normalizedCursor = clampCollapsedComposerCursor(value, cursor, includeSkillTokens); const previousSnapshot = snapshotRef.current; const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; const skillsChanged = skillsSignatureRef.current !== skillsSignature; + const includeSkillTokensChanged = includeSkillTokensRef.current !== includeSkillTokens; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && !contextsChanged && - !skillsChanged + !skillsChanged && + !includeSkillTokensChanged ) { return; } @@ -1589,24 +1617,39 @@ function ComposerPromptEditorInner({ snapshotRef.current = { value, cursor: normalizedCursor, - expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), + expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor, includeSkillTokens), terminalContextIds: terminalContexts.map((context) => context.id), }; terminalContextsSignatureRef.current = terminalContextsSignature; skillsSignatureRef.current = skillsSignature; + includeSkillTokensRef.current = includeSkillTokens; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !contextsChanged && !skillsChanged && !isFocused) { + if ( + previousSnapshot.value === value && + !contextsChanged && + !skillsChanged && + !includeSkillTokensChanged && + !isFocused + ) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || skillsChanged; + previousSnapshot.value !== value || + contextsChanged || + skillsChanged || + includeSkillTokensChanged; if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + $setComposerEditorPrompt( + value, + terminalContexts, + skillMetadataRef.current, + includeSkillTokens, + ); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); @@ -1615,13 +1658,25 @@ function ComposerPromptEditorInner({ queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]); + }, [ + cursor, + editor, + includeSkillTokens, + skillsSignature, + terminalContexts, + terminalContextsSignature, + value, + ]); const focusAt = useCallback( (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const boundedCursor = clampCollapsedComposerCursor( + snapshotRef.current.value, + nextCursor, + includeSkillTokens, + ); rootElement.focus({ preventScroll: true }); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); @@ -1629,7 +1684,11 @@ function ComposerPromptEditorInner({ snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), + expandedCursor: expandCollapsedComposerCursor( + snapshotRef.current.value, + boundedCursor, + includeSkillTokens, + ), terminalContextIds: snapshotRef.current.terminalContextIds, }; onChangeRef.current( @@ -1640,7 +1699,7 @@ function ComposerPromptEditorInner({ snapshotRef.current.terminalContextIds, ); }, - [editor], + [editor, includeSkillTokens], ); const readSnapshot = useCallback((): { @@ -1652,10 +1711,15 @@ function ComposerPromptEditorInner({ let snapshot = snapshotRef.current; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); + const fallbackCursor = clampCollapsedComposerCursor( + nextValue, + snapshotRef.current.cursor, + includeSkillTokens, + ); const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), + includeSkillTokens, ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, @@ -1675,7 +1739,7 @@ function ComposerPromptEditorInner({ }); snapshotRef.current = snapshot; return snapshot; - }, [editor]); + }, [editor, includeSkillTokens]); useImperativeHandle( editorRef, @@ -1689,6 +1753,7 @@ function ComposerPromptEditorInner({ collapseExpandedComposerCursor( snapshotRef.current.value, snapshotRef.current.value.length, + includeSkillTokens, ), ); }, @@ -1697,54 +1762,72 @@ function ComposerPromptEditorInner({ [focusAt, readSnapshot], ); - const handleEditorChange = useCallback((editorState: EditorState) => { - editorState.read(() => { - const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCollapsedComposerCursor( - nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), - ); - const fallbackExpandedCursor = clampExpandedCursor( - nextValue, - snapshotRef.current.expandedCursor, - ); - const nextExpandedCursor = clampExpandedCursor( - nextValue, - $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), - ); - const terminalContextIds = collectTerminalContextIds($getRoot()); - const previousSnapshot = snapshotRef.current; - if ( - previousSnapshot.value === nextValue && - previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.terminalContextIds.length === terminalContextIds.length && - previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) - ) { - return; - } - if (isApplyingControlledUpdateRef.current) { - return; - } - snapshotRef.current = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - terminalContextIds, - }; - const cursorAdjacentToMention = - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - terminalContextIds, - ); - }); - }, []); + const handleEditorChange = useCallback( + (editorState: EditorState) => { + editorState.read(() => { + const nextValue = $getRoot().getTextContent(); + const fallbackCursor = clampCollapsedComposerCursor( + nextValue, + snapshotRef.current.cursor, + includeSkillTokens, + ); + const nextCursor = clampCollapsedComposerCursor( + nextValue, + $readSelectionOffsetFromEditorState(fallbackCursor), + includeSkillTokens, + ); + const fallbackExpandedCursor = clampExpandedCursor( + nextValue, + snapshotRef.current.expandedCursor, + ); + const nextExpandedCursor = clampExpandedCursor( + nextValue, + $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), + ); + const terminalContextIds = collectTerminalContextIds($getRoot()); + const previousSnapshot = snapshotRef.current; + if ( + previousSnapshot.value === nextValue && + previousSnapshot.cursor === nextCursor && + previousSnapshot.expandedCursor === nextExpandedCursor && + previousSnapshot.terminalContextIds.length === terminalContextIds.length && + previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) + ) { + return; + } + if (isApplyingControlledUpdateRef.current) { + return; + } + snapshotRef.current = { + value: nextValue, + cursor: nextCursor, + expandedCursor: nextExpandedCursor, + terminalContextIds, + }; + const cursorAdjacentToMention = + isCollapsedCursorAdjacentToInlineToken( + nextValue, + nextCursor, + "left", + includeSkillTokens, + ) || + isCollapsedCursorAdjacentToInlineToken( + nextValue, + nextCursor, + "right", + includeSkillTokens, + ); + onChangeRef.current( + nextValue, + nextCursor, + nextExpandedCursor, + cursorAdjacentToMention, + terminalContextIds, + ); + }); + }, + [includeSkillTokens], + ); return ( @@ -1774,9 +1857,13 @@ function ComposerPromptEditorInner({ /> - + - + @@ -1792,6 +1879,7 @@ export function ComposerPromptEditor({ cursor, terminalContexts, skills, + includeSkillTokens = true, disabled, placeholder, className, @@ -1804,6 +1892,7 @@ export function ComposerPromptEditor({ const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); + const initialIncludeSkillTokensRef = useRef(includeSkillTokens); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", @@ -1814,6 +1903,7 @@ export function ComposerPromptEditor({ initialValueRef.current, initialTerminalContextsRef.current, initialSkillMetadataRef.current, + initialIncludeSkillTokensRef.current, ); }, onError: (error) => { @@ -1830,6 +1920,7 @@ export function ComposerPromptEditor({ cursor={cursor} terminalContexts={terminalContexts} skills={skills} + includeSkillTokens={includeSkillTokens} disabled={disabled} placeholder={placeholder} onRemoveTerminalContext={onRemoveTerminalContext} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a0518bdabef2..0a2b6577457b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -968,11 +968,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Composer-local state // ------------------------------------------------------------------ + const includeSkillTokens = activePendingProgress === null; const [composerCursor, setComposerCursor] = useState(() => - collapseExpandedComposerCursor(prompt, prompt.length), + collapseExpandedComposerCursor(prompt, prompt.length, includeSkillTokens), ); const [composerTrigger, setComposerTrigger] = useState(() => - detectComposerTrigger(prompt, prompt.length), + detectComposerTrigger(prompt, prompt.length, includeSkillTokens), ); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( @@ -1057,8 +1058,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Derived: composer trigger / menu // ------------------------------------------------------------------ - const composerTriggerKind = composerTrigger?.kind ?? null; - const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; + const availableComposerTrigger = + includeSkillTokens || composerTrigger?.kind !== "skill" ? composerTrigger : null; + const composerTriggerKind = availableComposerTrigger?.kind ?? null; + const pathTriggerQuery = + availableComposerTrigger?.kind === "path" ? availableComposerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; const workspaceEntries = useComposerPathSearch({ environmentId, @@ -1067,8 +1071,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const composerMenuItems = useMemo(() => { - if (!composerTrigger) return []; - if (composerTrigger.kind === "path") { + if (!availableComposerTrigger) return []; + if (availableComposerTrigger.kind === "path") { return workspaceEntries.entries.map((entry) => ({ id: `path:${entry.kind}:${entry.path}`, type: "path", @@ -1078,7 +1082,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) description: entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))), })); } - if (composerTrigger.kind === "slash-command") { + if (availableComposerTrigger.kind === "slash-command") { const builtInSlashCommandItems = [ { id: "slash:model", @@ -1116,20 +1120,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) description: command.description ?? command.input?.hint ?? "Run provider command", }), ); - const query = composerTrigger.query.trim().toLowerCase(); - const skillItems = (selectedProviderStatus?.skills ?? []) - .filter((skill) => skill.enabled) - .map((skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: `skill:${skill.name}`, - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : ""), - })); + const query = availableComposerTrigger.query.trim().toLowerCase(); + const skillItems = includeSkillTokens + ? (selectedProviderStatus?.skills ?? []) + .filter((skill) => skill.enabled) + .map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: `skill:${skill.name}`, + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : ""), + })) + : []; const slashCommandItems = [ ...builtInSlashCommandItems, ...providerSlashCommandItems, @@ -1137,33 +1143,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]; return searchSlashCommandItems(slashCommandItems, query); } - if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( - (skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - }), - ); + if (availableComposerTrigger.kind === "skill") { + return searchProviderSkills( + selectedProviderStatus?.skills ?? [], + availableComposerTrigger.query, + ).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return []; }, [ - composerTrigger, + availableComposerTrigger, + includeSkillTokens, planModeUiEnabled, selectedProvider, selectedProviderStatus, workspaceEntries.entries, ]); - const composerMenuOpen = Boolean(composerTrigger); - const composerMenuSearchKey = composerTrigger - ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` + const composerMenuOpen = Boolean(availableComposerTrigger); + const composerMenuSearchKey = availableComposerTrigger + ? `${availableComposerTrigger.kind}:${availableComposerTrigger.query.trim().toLowerCase()}` : null; const activeComposerMenuItem = useMemo(() => { const activeItemId = resolveComposerMenuActiveItemId({ @@ -1244,12 +1252,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); - const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); + const nextCursor = collapseExpandedComposerCursor( + nextPrompt, + nextPrompt.length, + includeSkillTokens, + ); setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length, includeSkillTokens)); scheduleComposerFocus(); }, - [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [ + composerDraftTarget, + includeSkillTokens, + promptRef, + scheduleComposerFocus, + setComposerDraftPrompt, + ], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -1343,13 +1361,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef.current = removal.prompt; setPrompt(removal.prompt); removeComposerDraftTerminalContext(composerDraftTarget, contextId); - const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor); + const nextCursor = collapseExpandedComposerCursor( + removal.prompt, + removal.cursor, + includeSkillTokens, + ); setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor)); + setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor, includeSkillTokens)); }, [ composerDraftTarget, composerTerminalContexts, + includeSkillTokens, promptRef, removeComposerDraftTerminalContext, setPrompt, @@ -1361,8 +1384,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ useEffect(() => { promptRef.current = prompt; - setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt, promptRef]); + setComposerCursor((existing) => + clampCollapsedComposerCursor(prompt, existing, includeSkillTokens), + ); + }, [includeSkillTokens, prompt, promptRef]); useEffect(() => { if (composerSubmissionError === null) return; @@ -1434,7 +1459,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { const nextCustomAnswer = activePendingProgress?.customAnswer; if (typeof nextCustomAnswer !== "string") { + const pendingInputEnded = lastSyncedPendingInputRef.current !== null; lastSyncedPendingInputRef.current = null; + if (!pendingInputEnded) { + return; + } + + promptRef.current = prompt; + const nextCursor = collapseExpandedComposerCursor(prompt, prompt.length, includeSkillTokens); + setComposerCursor(nextCursor); + setComposerTrigger( + detectComposerTrigger( + prompt, + expandCollapsedComposerCursor(prompt, nextCursor, includeSkillTokens), + includeSkillTokens, + ), + ); + setComposerHighlightedItemId(null); return; } @@ -1455,12 +1496,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); + const nextCursor = collapseExpandedComposerCursor( + nextCustomAnswer, + nextCustomAnswer.length, + includeSkillTokens, + ); setComposerCursor(nextCursor); setComposerTrigger( detectComposerTrigger( nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), + expandCollapsedComposerCursor(nextCustomAnswer, nextCursor, includeSkillTokens), + includeSkillTokens, ), ); setComposerHighlightedItemId(null); @@ -1468,6 +1514,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, + includeSkillTokens, + prompt, promptRef, ]); @@ -1478,10 +1526,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedItemId(null); setComposerSubmissionError(null); setProviderInputSubmissionError(null); - setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + setComposerCursor( + collapseExpandedComposerCursor( + promptRef.current, + promptRef.current.length, + includeSkillTokens, + ), + ); + setComposerTrigger( + detectComposerTrigger(promptRef.current, promptRef.current.length, includeSkillTokens), + ); setIsDragOverComposer(false); - }, [draftId, activeThreadId, promptRef]); + }, [draftId, activeThreadId, includeSkillTokens, promptRef]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -1611,7 +1667,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention + ? null + : detectComposerTrigger(nextPrompt, expandedCursor, includeSkillTokens), ); onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, @@ -1632,11 +1690,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + cursorAdjacentToMention + ? null + : detectComposerTrigger(nextPrompt, expandedCursor, includeSkillTokens), ); }, [ activePendingProgress?.activeQuestion, + includeSkillTokens, pendingUserInputs.length, onChangeActivePendingUserInputCustomAnswer, promptRef, @@ -1667,8 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return false; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); - const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); - const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); + const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor, includeSkillTokens); + const nextExpandedCursor = expandCollapsedComposerCursor( + next.text, + nextCursor, + includeSkillTokens, + ); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { @@ -1683,7 +1748,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setPrompt(next.text); } setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); + setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor, includeSkillTokens)); if (options?.focusEditorAfterReplace !== false) { window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCursor); @@ -1694,6 +1759,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activePendingProgress?.activeQuestion, activePendingUserInput, + includeSkillTokens, onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, @@ -1713,10 +1779,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return { value: promptRef.current, cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + expandedCursor: expandCollapsedComposerCursor( + promptRef.current, + composerCursor, + includeSkillTokens, + ), terminalContextIds: composerTerminalContexts.map((context) => context.id), }; - }, [composerCursor, composerTerminalContexts, promptRef]); + }, [composerCursor, composerTerminalContexts, includeSkillTokens, promptRef]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; @@ -1725,9 +1795,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const snapshot = readComposerSnapshot(); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor, includeSkillTokens), }; - }, [readComposerSnapshot]); + }, [includeSkillTokens, readComposerSnapshot]); const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { @@ -2057,7 +2127,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (promptChanged) { promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); - setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerCursor( + collapseExpandedComposerCursor(nextPrompt, nextPrompt.length, includeSkillTokens), + ); setComposerTrigger(null); } @@ -2137,6 +2209,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftImages, composerDraftTarget, composerImagesRef, + includeSkillTokens, promptRef, setComposerDraftPrompt, takeStashEntry, @@ -2706,14 +2779,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) detectTrigger?: boolean; }) => { const promptForState = options?.prompt ?? promptRef.current; - const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); + const cursor = clampCollapsedComposerCursor( + promptForState, + options?.cursor ?? 0, + includeSkillTokens, + ); setComposerHighlightedItemId(null); setComposerCursor(cursor); setComposerTrigger( options?.detectTrigger ? detectComposerTrigger( promptForState, - expandCollapsedComposerCursor(promptForState, cursor), + expandCollapsedComposerCursor(promptForState, cursor, includeSkillTokens), + includeSkillTokens, ) : null, ); @@ -2723,7 +2801,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const snapshot = composerEditorRef.current?.readSnapshot() ?? { value: promptRef.current, cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + expandedCursor: expandCollapsedComposerCursor( + promptRef.current, + composerCursor, + includeSkillTokens, + ), terminalContextIds: composerTerminalContexts.map((context) => context.id), }; const insertion = insertInlineTerminalContextPlaceholder( @@ -2733,6 +2815,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const nextCollapsedCursor = collapseExpandedComposerCursor( insertion.prompt, insertion.cursor, + includeSkillTokens, ); const inserted = insertComposerDraftTerminalContext( composerDraftTarget, @@ -2748,7 +2831,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!inserted) return; promptRef.current = insertion.prompt; setComposerCursor(nextCollapsedCursor); - setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); + setComposerTrigger( + detectComposerTrigger(insertion.prompt, insertion.cursor, includeSkillTokens), + ); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCollapsedCursor); }); @@ -2786,6 +2871,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, + includeSkillTokens, promptRef, composerImagesRef, composerTerminalContextsRef, @@ -3221,6 +3307,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : [] } skills={selectedProviderStatus?.skills ?? []} + includeSkillTokens={includeSkillTokens} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 70c974003707..6f87be51d48e 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -126,6 +126,12 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); + it("keeps completed skill syntax as text when skill tokens are disabled", () => { + expect(splitPromptIntoComposerSegments("Use $find-skills please", [], false)).toEqual([ + { type: "text", text: "Use $find-skills please" }, + ]); + }); + it("keeps inline terminal context placeholders at their prompt positions", () => { expect( splitPromptIntoComposerSegments( diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 8b9a53808f8b..887e797c5850 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -124,13 +124,18 @@ function forEachMentionMatch( }); } -function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] { +function splitPromptTextIntoComposerSegments( + text: string, + includeSkillTokens: boolean, +): ComposerPromptSegment[] { const segments: ComposerPromptSegment[] = []; if (!text) { return segments; } - const tokenMatches = collectComposerInlineTokens(text); + const tokenMatches = collectComposerInlineTokens(text).filter( + (token) => includeSkillTokens || token.type !== "skill", + ); let cursor = 0; for (const match of tokenMatches) { if (match.start < cursor) { @@ -198,6 +203,7 @@ export function selectionTouchesMentionBoundary( export function splitPromptIntoComposerSegments( prompt: string, terminalContexts: ReadonlyArray = [], + includeSkillTokens = true, ): ComposerPromptSegment[] { if (!prompt) { return []; @@ -207,7 +213,7 @@ export function splitPromptIntoComposerSegments( let terminalContextIndex = 0; forEachPromptSegmentSlice(prompt, (slice) => { if (slice.type === "text") { - segments.push(...splitPromptTextIntoComposerSegments(slice.text)); + segments.push(...splitPromptTextIntoComposerSegments(slice.text, includeSkillTokens)); return false; } diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index b8ef7443611a..c6fc3be5cf1b 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -106,6 +106,14 @@ describe("detectComposerTrigger", () => { }); }); + it("keeps $skill syntax inert when skill tokens are disabled", () => { + const skillText = "Use $find-skills"; + expect(detectComposerTrigger(skillText, skillText.length, false)).toBeNull(); + + const pathText = "Use @AGENTS"; + expect(detectComposerTrigger(pathText, pathText.length, false)?.kind).toBe("path"); + }); + it("detects @path trigger in the middle of existing text", () => { // User typed @ between "inspect " and "in this sentence" const text = "Please inspect @in this sentence"; @@ -209,6 +217,11 @@ describe("expandCollapsedComposerCursor", () => { expandedCursorAfterSkill, ); }); + + it("keeps skill syntax cursor offsets unchanged when skill tokens are disabled", () => { + const text = "run $find-skills "; + expect(expandCollapsedComposerCursor(text, text.length, false)).toBe(text.length); + }); }); describe("collapseExpandedComposerCursor", () => { @@ -273,6 +286,11 @@ describe("collapseExpandedComposerCursor", () => { collapsedCursorAfterSkill, ); }); + + it("keeps expanded skill syntax offsets unchanged when skill tokens are disabled", () => { + const text = "run $find-skills "; + expect(collapseExpandedComposerCursor(text, text.length, false)).toBe(text.length); + }); }); describe("clampCollapsedComposerCursor", () => { diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 2d1d3aed3b1e..1d88695cb18f 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -49,9 +49,13 @@ function tokenStartForCursor(text: string, cursor: number): number { return index + 1; } -export function expandCollapsedComposerCursor(text: string, cursorInput: number): number { +export function expandCollapsedComposerCursor( + text: string, + cursorInput: number, + includeSkillTokens = true, +): number { const collapsedCursor = clampCursor(text, cursorInput); - const segments = splitPromptIntoComposerSegments(text); + const segments = splitPromptIntoComposerSegments(text, [], includeSkillTokens); if (segments.length === 0) { return collapsedCursor; } @@ -130,16 +134,24 @@ function clampCollapsedComposerCursorForSegments( return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); } -export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { +export function clampCollapsedComposerCursor( + text: string, + cursorInput: number, + includeSkillTokens = true, +): number { return clampCollapsedComposerCursorForSegments( - splitPromptIntoComposerSegments(text), + splitPromptIntoComposerSegments(text, [], includeSkillTokens), cursorInput, ); } -export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { +export function collapseExpandedComposerCursor( + text: string, + cursorInput: number, + includeSkillTokens = true, +): number { const expandedCursor = clampCursor(text, cursorInput); - const segments = splitPromptIntoComposerSegments(text); + const segments = splitPromptIntoComposerSegments(text, [], includeSkillTokens); if (segments.length === 0) { return expandedCursor; } @@ -196,8 +208,9 @@ export function isCollapsedCursorAdjacentToInlineToken( text: string, cursorInput: number, direction: "left" | "right", + includeSkillTokens = true, ): boolean { - const segments = splitPromptIntoComposerSegments(text); + const segments = splitPromptIntoComposerSegments(text, [], includeSkillTokens); if (!segments.some(isInlineTokenSegment)) { return false; } @@ -222,7 +235,11 @@ export function isCollapsedCursorAdjacentToInlineToken( export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; -export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { +export function detectComposerTrigger( + text: string, + cursorInput: number, + includeSkillTokens = true, +): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; const linePrefix = text.slice(lineStart, cursor); @@ -242,7 +259,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos const tokenStart = tokenStartForCursor(text, cursor); const token = text.slice(tokenStart, cursor); - if (token.startsWith("$")) { + if (includeSkillTokens && token.startsWith("$")) { return { kind: "skill", query: token.slice(1),