Skip to content

feat: display rules used #5677

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
May 15, 2025
Merged
19 changes: 11 additions & 8 deletions core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ export interface ChatHistoryItem {
toolCallState?: ToolCallState;
isGatheringContext?: boolean;
reasoning?: Reasoning;
appliedRules?: RuleWithSource[];
}

export interface LLMFullCompletionOptions extends BaseCompletionOptions {
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions core/llm/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ describe("LLM", () => {
testFim: true,
skip: false,
testToolCall: true,
timeout: 60000,
},
);
testLLM(
Expand Down
41 changes: 26 additions & 15 deletions core/llm/rules/getSystemMessageWithRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,43 @@ 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;
const matchesAnyFilePath = filePathsFromMessage.some((path) =>
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;
Expand Down
10 changes: 9 additions & 1 deletion extensions/vscode/e2e/selectors/GUI.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`);
}
Expand Down
45 changes: 44 additions & 1 deletion extensions/vscode/e2e/tests/GUI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Expand Down
17 changes: 10 additions & 7 deletions gui/src/components/ToggleDiv.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@ interface ToggleProps {
children: React.ReactNode;
title: React.ReactNode;
icon?: ComponentType<React.SVGProps<SVGSVGElement>>;
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 (
<div
className={`pl-2 pt-2`}
className={`pl-2`}
style={{
backgroundColor: vscBackground,
}}
Expand All @@ -24,7 +30,7 @@ function ToggleDiv({ children, title, icon: Icon }: ToggleProps) {
onClick={() => setOpen((prev) => !prev)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
data-testid="context-items-peek"
data-testid={testId}
>
<div className="relative mr-1 h-4 w-4">
{Icon && !isHovered && !open ? (
Expand All @@ -44,10 +50,7 @@ function ToggleDiv({ children, title, icon: Icon }: ToggleProps) {
</>
)}
</div>
<span
className="ml-1 text-xs text-gray-400 transition-colors duration-200"
data-testid="toggle-div-title"
>
<span className="ml-1 text-xs text-gray-400 transition-colors duration-200">
{title}
</span>
</div>
Expand Down
19 changes: 14 additions & 5 deletions gui/src/components/mainInput/ContinueInputBox.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
}
Expand Down Expand Up @@ -116,6 +118,8 @@ function ContinueInputBox(props: ContinueInputBoxProps) {
}
: {};

const { appliedRules = [], contextItems = [] } = props;

return (
<div
className={`${props.hidden ? "hidden" : ""}`}
Expand Down Expand Up @@ -143,10 +147,15 @@ function ContinueInputBox(props: ContinueInputBoxProps) {
/>
</GradientBorder>
</div>
<ContextItemsPeek
contextItems={props.contextItems}
isCurrentContextPeek={props.isLastUserInput}
/>
{(appliedRules.length > 0 || contextItems.length > 0) && (
<div className="mt-2 flex flex-col">
<RulesPeek appliedRules={props.appliedRules} />
<ContextItemsPeek
contextItems={props.contextItems}
isCurrentContextPeek={props.isLastUserInput}
/>
</div>
)}
</div>
);
}
Expand Down
Loading
Loading