diff --git a/core/index.d.ts b/core/index.d.ts index d15c73b50a..501204dca3 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -459,6 +459,7 @@ export interface ChatHistoryItem { toolCallState?: ToolCallState; isGatheringContext?: boolean; reasoning?: Reasoning; + appliedRules?: RuleWithSource[]; } export interface LLMFullCompletionOptions extends BaseCompletionOptions { @@ -1497,17 +1498,19 @@ export interface TerminalOptions { waitForCompletion?: boolean; } +export type RuleSource = + | "default-chat" + | "default-agent" + | "model-chat-options" + | "model-agent-options" + | "rules-block" + | "json-systemMessage" + | ".continuerules"; + export interface RuleWithSource { name?: string; slug?: string; - source: - | "default-chat" - | "default-agent" - | "model-chat-options" - | "model-agent-options" - | "rules-block" - | "json-systemMessage" - | ".continuerules"; + source: RuleSource; globs?: string | string[]; rule: string; description?: string; diff --git a/core/llm/llm.test.ts b/core/llm/llm.test.ts index d1067a8bc7..7fb840d610 100644 --- a/core/llm/llm.test.ts +++ b/core/llm/llm.test.ts @@ -230,6 +230,7 @@ describe("LLM", () => { testFim: true, skip: false, testToolCall: true, + timeout: 60000, }, ); testLLM( diff --git a/core/llm/rules/getSystemMessageWithRules.ts b/core/llm/rules/getSystemMessageWithRules.ts index dacd941bb1..3d7372fef9 100644 --- a/core/llm/rules/getSystemMessageWithRules.ts +++ b/core/llm/rules/getSystemMessageWithRules.ts @@ -23,22 +23,18 @@ const matchesGlobs = ( return false; }; -export const getSystemMessageWithRules = ({ - baseSystemMessage, - userMessage, - rules, -}: { - baseSystemMessage?: string; - userMessage: UserChatMessage | ToolResultChatMessage | undefined; - rules: RuleWithSource[]; -}) => { +/** + * Filters rules that apply to the given message + */ +export const getApplicableRules = ( + userMessage: UserChatMessage | ToolResultChatMessage | undefined, + rules: RuleWithSource[], +): RuleWithSource[] => { const filePathsFromMessage = userMessage ? extractPathsFromCodeBlocks(renderChatMessage(userMessage)) : []; - let systemMessage = baseSystemMessage ?? ""; - - for (const rule of rules) { + return rules.filter((rule) => { // A rule is active if it has no globs (applies to all files) // or if at least one file path matches its globs const hasNoGlobs = !rule.globs; @@ -46,9 +42,24 @@ export const getSystemMessageWithRules = ({ matchesGlobs(path, rule.globs), ); - if (hasNoGlobs || matchesAnyFilePath) { - systemMessage += `\n\n${rule.rule}`; - } + return hasNoGlobs || matchesAnyFilePath; + }); +}; + +export const getSystemMessageWithRules = ({ + baseSystemMessage, + userMessage, + rules, +}: { + baseSystemMessage?: string; + userMessage: UserChatMessage | ToolResultChatMessage | undefined; + rules: RuleWithSource[]; +}) => { + const applicableRules = getApplicableRules(userMessage, rules); + let systemMessage = baseSystemMessage ?? ""; + + for (const rule of applicableRules) { + systemMessage += `\n\n${rule.rule}`; } return systemMessage; diff --git a/extensions/vscode/e2e/selectors/GUI.selectors.ts b/extensions/vscode/e2e/selectors/GUI.selectors.ts index e51bfd6816..773e3cfab2 100644 --- a/extensions/vscode/e2e/selectors/GUI.selectors.ts +++ b/extensions/vscode/e2e/selectors/GUI.selectors.ts @@ -46,7 +46,7 @@ export class GUISelectors { } public static getToolCallStatusMessage(view: WebView) { - return SelectorUtils.getElementByDataTestId(view, "toggle-div-title"); + return SelectorUtils.getElementByDataTestId(view, "tool-call-title"); } public static getToolButton(view: WebView) { @@ -89,6 +89,14 @@ export class GUISelectors { ); } + public static getRulesPeek(view: WebView) { + return SelectorUtils.getElementByDataTestId(view, "rules-peek"); + } + + public static getFirstRulesPeekItem(view: WebView) { + return SelectorUtils.getElementByDataTestId(view, "rules-peek-item"); + } + public static getNthHistoryTableRow(view: WebView, index: number) { return SelectorUtils.getElementByDataTestId(view, `history-row-${index}`); } diff --git a/extensions/vscode/e2e/tests/GUI.test.ts b/extensions/vscode/e2e/tests/GUI.test.ts index 55493a28c3..b053022266 100644 --- a/extensions/vscode/e2e/tests/GUI.test.ts +++ b/extensions/vscode/e2e/tests/GUI.test.ts @@ -245,6 +245,47 @@ describe("GUI Test", () => { await GUIActions.selectModeFromDropdown(view, "Agent"); }); + it("should display rules peek and show rule details", async () => { + // Send a message to trigger the model response + const [messageInput] = await GUISelectors.getMessageInputFields(view); + await messageInput.sendKeys("Hello"); + await messageInput.sendKeys(Key.ENTER); + + // Wait for the response to appear + await TestUtils.waitForSuccess(() => + GUISelectors.getThreadMessageByText(view, "I'm going to call a tool:"), + ); + + // Verify that "1 rule" text appears + const rulesPeek = await TestUtils.waitForSuccess(() => + GUISelectors.getRulesPeek(view), + ); + const rulesPeekText = await rulesPeek.getText(); + expect(rulesPeekText).to.include("1 rule"); + + // Click on the rules peek to expand it + await rulesPeek.click(); + + // Wait for the rule details to appear + const ruleItem = await TestUtils.waitForSuccess(() => + GUISelectors.getFirstRulesPeekItem(view), + ); + + await TestUtils.waitForSuccess(async () => { + const text = await ruleItem.getText(); + if (!text || text.trim() === "") { + throw new Error("Rule item text is empty"); + } + return ruleItem; + }); + + // Verify the rule content + const ruleItemText = await ruleItem.getText(); + expect(ruleItemText).to.include("Assistant rule"); + expect(ruleItemText).to.include("Always applied"); + expect(ruleItemText).to.include("TEST_SYS_MSG"); + }).timeout(DEFAULT_TIMEOUT.MD); + it("should render tool call", async () => { const [messageInput] = await GUISelectors.getMessageInputFields(view); await messageInput.sendKeys("Hello"); @@ -258,7 +299,9 @@ describe("GUI Test", () => { expect(await statusMessage.getText()).contain( "Continue viewed the git diff", ); - }).timeout(DEFAULT_TIMEOUT.MD); + // wait for 30 seconds, promise + await new Promise((resolve) => setTimeout(resolve, 30000)); + }).timeout(DEFAULT_TIMEOUT.MD * 100); it("should call tool after approval", async () => { await GUIActions.toggleToolPolicy(view, "builtin_view_diff", 2); diff --git a/gui/src/components/ToggleDiv.tsx b/gui/src/components/ToggleDiv.tsx index 82536fe105..988cfd22ba 100644 --- a/gui/src/components/ToggleDiv.tsx +++ b/gui/src/components/ToggleDiv.tsx @@ -6,15 +6,21 @@ interface ToggleProps { children: React.ReactNode; title: React.ReactNode; icon?: ComponentType>; + testId?: string; } -function ToggleDiv({ children, title, icon: Icon }: ToggleProps) { +function ToggleDiv({ + children, + title, + icon: Icon, + testId = "context-items-peek", +}: ToggleProps) { const [open, setOpen] = useState(false); const [isHovered, setIsHovered] = useState(false); return (
setOpen((prev) => !prev)} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - data-testid="context-items-peek" + data-testid={testId} >
{Icon && !isHovered && !open ? ( @@ -44,10 +50,7 @@ function ToggleDiv({ children, title, icon: Icon }: ToggleProps) { )}
- + {title}
diff --git a/gui/src/components/mainInput/ContinueInputBox.tsx b/gui/src/components/mainInput/ContinueInputBox.tsx index c9a1510795..f3880249e8 100644 --- a/gui/src/components/mainInput/ContinueInputBox.tsx +++ b/gui/src/components/mainInput/ContinueInputBox.tsx @@ -1,11 +1,12 @@ import { Editor, JSONContent } from "@tiptap/react"; -import { ContextItemWithId, InputModifiers } from "core"; +import { ContextItemWithId, InputModifiers, RuleWithSource } from "core"; import { useMemo } from "react"; import styled, { keyframes } from "styled-components"; import { defaultBorderRadius, vscBackground } from ".."; import { useAppSelector } from "../../redux/hooks"; import { selectSlashCommandComboBoxInputs } from "../../redux/selectors"; import { ContextItemsPeek } from "./belowMainInput/ContextItemsPeek"; +import { RulesPeek } from "./belowMainInput/RulesPeek"; import { ToolbarOptions } from "./InputToolbar"; import { Lump } from "./Lump"; import { TipTapEditor } from "./TipTapEditor"; @@ -20,6 +21,7 @@ interface ContinueInputBoxProps { ) => void; editorState?: JSONContent; contextItems?: ContextItemWithId[]; + appliedRules?: RuleWithSource[]; hidden?: boolean; inputId: string; // used to keep track of things per input in redux } @@ -116,6 +118,8 @@ function ContinueInputBox(props: ContinueInputBoxProps) { } : {}; + const { appliedRules = [], contextItems = [] } = props; + return (
- + {(appliedRules.length > 0 || contextItems.length > 0) && ( +
+ + +
+ )} ); } diff --git a/gui/src/components/mainInput/belowMainInput/RulesPeek.tsx b/gui/src/components/mainInput/belowMainInput/RulesPeek.tsx new file mode 100644 index 0000000000..be40ac9073 --- /dev/null +++ b/gui/src/components/mainInput/belowMainInput/RulesPeek.tsx @@ -0,0 +1,122 @@ +import { DocumentTextIcon, GlobeAltIcon } from "@heroicons/react/24/outline"; +import { RuleSource, RuleWithSource } from "core"; +import { ComponentType, useMemo, useState } from "react"; +import ToggleDiv from "../../ToggleDiv"; + +interface RulesPeekProps { + appliedRules?: RuleWithSource[]; + icon?: ComponentType>; +} + +interface RulesPeekItemProps { + rule: RuleWithSource; +} + +// Convert technical source to user-friendly text +const getSourceLabel = (source: RuleSource): string => { + switch (source) { + case "default-chat": + return "Default Chat"; + case "default-agent": + return "Default Agent"; + case "model-chat-options": + return "Model Chat Options"; + case "model-agent-options": + return "Model Agent Options"; + case "rules-block": + return "Rules Block"; + case "json-systemMessage": + return "System Message"; + case ".continuerules": + return "Project Rules"; + default: + return source; + } +}; + +export function RulesPeekItem({ rule }: RulesPeekItemProps) { + const isGlobal = !rule.globs; + const [expanded, setExpanded] = useState(false); + + // Define maximum length for rule text display + const maxRuleLength = 100; + const isRuleLong = rule.rule.length > maxRuleLength; + + // Get the displayed rule text based on expanded state + const displayedRule = + isRuleLong && !expanded + ? `${rule.rule.slice(0, maxRuleLength)}...` + : rule.rule; + + const toggleExpand = () => { + if (isRuleLong) { + setExpanded(!expanded); + } + }; + + return ( +
+
+ {isGlobal ? ( + + ) : ( + + )} + +
+
+ {rule.name || "Assistant rule"} +
+ +
+ {isGlobal + ? "Always applied" + : `Pattern: ${typeof rule.globs === "string" ? rule.globs : Array.isArray(rule.globs) ? rule.globs.join(", ") : ""}`} +
+
+
+
+ {displayedRule} + {isRuleLong && ( + + {expanded ? "(collapse)" : "(expand)"} + + )} +
+
+ Source: {getSourceLabel(rule.source)} +
+
+ ); +} + +export function RulesPeek({ appliedRules, icon }: RulesPeekProps) { + const rules = useMemo(() => { + return appliedRules ?? []; + }, [appliedRules]); + + if (!rules || rules.length === 0) { + return null; + } + + return ( + 1 ? "s" : ""}`} + testId="rules-peek" + > + {rules.map((rule, idx) => ( + + ))} + + ); +} diff --git a/gui/src/pages/gui/Chat.tsx b/gui/src/pages/gui/Chat.tsx index 2514909bcf..ff19b3515c 100644 --- a/gui/src/pages/gui/Chat.tsx +++ b/gui/src/pages/gui/Chat.tsx @@ -320,6 +320,7 @@ export function Chat() { isMainInput={false} editorState={item.editorState} contextItems={item.contextItems} + appliedRules={item.appliedRules} inputId={item.message.id} /> diff --git a/gui/src/pages/gui/ToolCallDiv/SimpleToolCallUI.tsx b/gui/src/pages/gui/ToolCallDiv/SimpleToolCallUI.tsx index cec59e2209..3342eabda6 100644 --- a/gui/src/pages/gui/ToolCallDiv/SimpleToolCallUI.tsx +++ b/gui/src/pages/gui/ToolCallDiv/SimpleToolCallUI.tsx @@ -61,7 +61,7 @@ export function SimpleToolCallUI({ diff --git a/gui/src/redux/slices/sessionSlice.ts b/gui/src/redux/slices/sessionSlice.ts index e81545d8b9..3fe15881c5 100644 --- a/gui/src/redux/slices/sessionSlice.ts +++ b/gui/src/redux/slices/sessionSlice.ts @@ -15,6 +15,7 @@ import { FileSymbolMap, MessageModes, PromptLog, + RuleWithSource, Session, SessionMetadata, ToolCallDelta, @@ -247,6 +248,19 @@ export const sessionSlice = createSlice({ ...payload.contextItems, ]; }, + setAppliedRulesAtIndex: ( + state, + { + payload, + }: PayloadAction<{ + index: number; + appliedRules: RuleWithSource[]; + }>, + ) => { + if (state.history[payload.index]) { + state.history[payload.index].appliedRules = payload.appliedRules; + } + }, setInactive: (state) => { const curMessage = state.history.at(-1); @@ -698,6 +712,7 @@ export const { updateFileSymbols, setContextItemsAtIndex, addContextItemsAtIndex, + setAppliedRulesAtIndex, setInactive, streamUpdate, newSession, diff --git a/gui/src/redux/thunks/streamResponse.ts b/gui/src/redux/thunks/streamResponse.ts index 5ab6f036ec..c9a21a30c8 100644 --- a/gui/src/redux/thunks/streamResponse.ts +++ b/gui/src/redux/thunks/streamResponse.ts @@ -1,12 +1,14 @@ import { createAsyncThunk, unwrapResult } from "@reduxjs/toolkit"; import { JSONContent } from "@tiptap/core"; -import { InputModifiers } from "core"; +import { InputModifiers, ToolResultChatMessage, UserChatMessage } from "core"; import { constructMessages } from "core/llm/constructMessages"; +import { getApplicableRules } from "core/llm/rules/getSystemMessageWithRules"; import posthog from "posthog-js"; import { v4 as uuidv4 } from "uuid"; import { getBaseSystemMessage } from "../../util"; import { selectSelectedChatModel } from "../slices/configSlice"; import { + setAppliedRulesAtIndex, submitEditorAndInitAtIndex, updateHistoryItemAtIndex, } from "../slices/sessionSlice"; @@ -84,17 +86,41 @@ export const streamResponseThunk = createAsyncThunk< }), ); - // Construct messages from updated history + // Get updated history after the update const updatedHistory = getState().session.history; - const messageMode = getState().session.mode - const baseChatOrAgentSystemMessage = getBaseSystemMessage(selectedChatModel, messageMode) + // Determine which rules apply to this message + const userMsg = updatedHistory[inputIndex].message; + const rules = getState().config.config.rules; + + // Calculate applicable rules once + // We need to check the message type to match what getApplicableRules expects + const applicableRules = getApplicableRules( + userMsg.role === "user" || userMsg.role === "tool" + ? (userMsg as UserChatMessage | ToolResultChatMessage) + : undefined, + rules, + ); + + // Store in history for UI display + dispatch( + setAppliedRulesAtIndex({ + index: inputIndex, + appliedRules: applicableRules, + }), + ); + + const messageMode = getState().session.mode; + const baseChatOrAgentSystemMessage = getBaseSystemMessage( + selectedChatModel, + messageMode, + ); const messages = constructMessages( messageMode, [...updatedHistory], baseChatOrAgentSystemMessage, - state.config.config.rules, + applicableRules, ); posthog.capture("step run", {