Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .claude/commands/create-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ Create a pull request for the current branch.

## Step 2: Analyze Changes

First, determine the base branch to diff against:
- Run `git fetch origin main --quiet` to ensure the remote ref is up to date
- Use `origin/main` (not local `main`) as the base for all diff/log commands — local `main` may be stale

Run in parallel:
- `git log main..HEAD --oneline` — commit history
- `git log main..HEAD --format="%B---"` — full commit messages for context
- `git diff main...HEAD --stat` — file change overview
- `git diff main...HEAD` — full diff
- `git log origin/main..HEAD --oneline` — commit history
- `git log origin/main..HEAD --format="%B---"` — full commit messages for context
- `git diff origin/main...HEAD --stat` — file change overview
- `git diff origin/main...HEAD` — full diff

Read the diff carefully to understand what changed and why.

Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/lib/trpc/routers/ai-chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { publicProcedure, router } from "../..";
Expand All @@ -11,6 +14,49 @@ import {
sessionStore,
} from "./utils/session-manager";

interface CommandEntry {
name: string;
description: string;
argumentHint: string;
}

function scanCustomCommands(cwd: string): CommandEntry[] {
const dirs = [
join(cwd, ".claude", "commands"),
join(homedir(), ".claude", "commands"),
];
const commands: CommandEntry[] = [];
const seen = new Set<string>();

for (const dir of dirs) {
if (!existsSync(dir)) continue;
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const name = file.replace(/\.md$/, "");
if (seen.has(name)) continue;
seen.add(name);
const raw = readFileSync(join(dir, file), "utf-8");
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
const descMatch = fmMatch?.[1]?.match(/^description:\s*(.+)$/m);
const argMatch = fmMatch?.[1]?.match(/^argument-hint:\s*(.+)$/m);
Comment on lines +40 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Front-matter regex won't match on Windows due to \r\n line endings.

readFileSync preserves OS-native line endings. On Windows, ^---\n won't match ---\r\n, so description and argumentHint will silently fall back to "" for all commands.

Proposed fix — use `\r?\n` in the regex
-			const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
-			const descMatch = fmMatch?.[1]?.match(/^description:\s*(.+)$/m);
-			const argMatch = fmMatch?.[1]?.match(/^argument-hint:\s*(.+)$/m);
+			const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+			const descMatch = fmMatch?.[1]?.match(/^description:\s*(.+)$/m);
+			const argMatch = fmMatch?.[1]?.match(/^argument-hint:\s*(.+)$/m);

Note: the $/m flag in the inner regexes will match before \n, but .+ will greedily consume a trailing \r. You may want to .trim() the captures (already done on lines 45-46, so those are fine).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
const descMatch = fmMatch?.[1]?.match(/^description:\s*(.+)$/m);
const argMatch = fmMatch?.[1]?.match(/^argument-hint:\s*(.+)$/m);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const descMatch = fmMatch?.[1]?.match(/^description:\s*(.+)$/m);
const argMatch = fmMatch?.[1]?.match(/^argument-hint:\s*(.+)$/m);
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/ai-chat/index.ts` around lines 40 - 42, The
front-matter regex using raw.match(/^---\n([\s\S]*?)\n---/) fails on Windows
CRLF endings; update the raw.match pattern in the fmMatch expression to accept
optional CR (use \r?\n) so it matches both LF and CRLF, and ensure the
subsequent descMatch and argMatch (the captures from fmMatch used in descMatch
and argMatch) still have their values trimmed (they are already trimmed later)
to remove any leftover \r characters.

commands.push({
name,
description: descMatch?.[1]?.trim() ?? "",
argumentHint: argMatch?.[1]?.trim() ?? "",
});
}
} catch (err) {
console.warn(
`[ai-chat/scanCustomCommands] Failed to read commands from ${dir}:`,
err,
);
}
}

return commands;
}

export const createAiChatRouter = () => {
return router({
getConfig: publicProcedure.query(() => ({
Expand All @@ -21,6 +67,12 @@ export const createAiChatRouter = () => {
null,
})),

getSlashCommands: publicProcedure
.input(z.object({ cwd: z.string() }))
.query(({ input }) => {
return { commands: scanCustomCommands(input.cwd) };
}),

startSession: publicProcedure
.input(
z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ import {
} from "./components/FileMentionPopover";
import { ModelPicker } from "./components/ModelPicker";
import { PermissionModePicker } from "./components/PermissionModePicker";
import { SlashCommandInput } from "./components/SlashCommandInput";
import { MODELS } from "./constants";
import { useClaudeCodeHistory } from "./hooks/useClaudeCodeHistory";
import type { SlashCommand } from "./hooks/useSlashCommands";
import type { ModelOption, PermissionMode } from "./types";

interface ChatInterfaceProps {
Expand Down Expand Up @@ -269,6 +271,13 @@ export function ChatInterface({
[stop],
);

const handleSlashCommandSend = useCallback(
(command: SlashCommand) => {
handleSend({ text: `/${command.name}` });
},
[handleSend],
);

return (
<div className="flex h-full flex-col bg-background">
{connectionStatus !== "connected" &&
Expand Down Expand Up @@ -316,43 +325,48 @@ export function ChatInterface({
)}
<PromptInputProvider>
<FileMentionProvider cwd={cwd}>
<FileMentionAnchor>
<PromptInput onSubmit={handleSend}>
<PromptInputTextarea placeholder="Ask anything..." />
<PromptInputFooter>
<PromptInputTools>
<PromptInputButton>
<HiMiniPaperClip className="size-4" />
</PromptInputButton>
<FileMentionTrigger />
<ThinkingToggle
enabled={thinkingEnabled}
onToggle={handleThinkingToggle}
/>
<ModelPicker
selectedModel={selectedModel}
onSelectModel={handleModelSelect}
open={modelSelectorOpen}
onOpenChange={setModelSelectorOpen}
/>
<PermissionModePicker
selectedMode={permissionMode}
onSelectMode={handlePermissionModeSelect}
/>
</PromptInputTools>
<div className="flex items-center gap-1">
<ContextIndicator
collections={collections}
modelId={selectedModel.id}
/>
<PromptInputSubmit
status={isLoading ? "streaming" : undefined}
onClick={isLoading ? handleStop : undefined}
/>
</div>
</PromptInputFooter>
</PromptInput>
</FileMentionAnchor>
<SlashCommandInput
onCommandSend={handleSlashCommandSend}
cwd={cwd}
>
<FileMentionAnchor>
<PromptInput onSubmit={handleSend}>
<PromptInputTextarea placeholder="Ask anything..." />
<PromptInputFooter>
<PromptInputTools>
<PromptInputButton>
<HiMiniPaperClip className="size-4" />
</PromptInputButton>
<FileMentionTrigger />
<ThinkingToggle
enabled={thinkingEnabled}
onToggle={handleThinkingToggle}
/>
<ModelPicker
selectedModel={selectedModel}
onSelectModel={handleModelSelect}
open={modelSelectorOpen}
onOpenChange={setModelSelectorOpen}
/>
<PermissionModePicker
selectedMode={permissionMode}
onSelectMode={handlePermissionModeSelect}
/>
</PromptInputTools>
<div className="flex items-center gap-1">
<ContextIndicator
collections={collections}
modelId={selectedModel.id}
/>
<PromptInputSubmit
status={isLoading ? "streaming" : undefined}
onClick={isLoading ? handleStop : undefined}
/>
</div>
</PromptInputFooter>
</PromptInput>
</FileMentionAnchor>
</SlashCommandInput>
</FileMentionProvider>
</PromptInputProvider>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { usePromptInputController } from "@superset/ui/ai-elements/prompt-input";
import { Popover, PopoverAnchor } from "@superset/ui/popover";
import { useCallback } from "react";
import {
resolveCommandAction,
type SlashCommand,
useSlashCommands,
} from "../../hooks/useSlashCommands";
import { SlashCommandMenu } from "../SlashCommandMenu";

interface SlashCommandInputProps {
onCommandSend: (command: SlashCommand) => void;
cwd: string;
children: React.ReactNode;
}

export function SlashCommandInput({
onCommandSend,
cwd,
children,
}: SlashCommandInputProps) {
const { textInput } = usePromptInputController();

const slashCommands = useSlashCommands({
inputValue: textInput.value,
cwd,
});

const executeCommand = useCallback(
(command: SlashCommand) => {
const action = resolveCommandAction(command);
if (action.shouldSend) {
onCommandSend(command);
}
textInput.setInput(action.text);
},
[onCommandSend, textInput],
);

const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent) => {
if (!slashCommands.isOpen) return;

switch (e.key) {
case "Escape":
e.preventDefault();
e.stopPropagation();
textInput.setInput("");
break;
case "Enter":
case "Tab": {
e.preventDefault();
e.stopPropagation();
const cmd =
slashCommands.filteredCommands[slashCommands.selectedIndex];
if (cmd) executeCommand(cmd);
break;
}
case "ArrowUp":
e.preventDefault();
e.stopPropagation();
slashCommands.navigateUp();
break;
case "ArrowDown":
e.preventDefault();
e.stopPropagation();
slashCommands.navigateDown();
break;
}
},
[slashCommands, textInput, executeCommand],
);

return (
<Popover open={slashCommands.isOpen}>
<PopoverAnchor asChild>
<div onKeyDownCapture={handleKeyDownCapture}>{children}</div>
</PopoverAnchor>
<SlashCommandMenu
commands={slashCommands.filteredCommands}
selectedIndex={slashCommands.selectedIndex}
onSelect={executeCommand}
onHover={slashCommands.setSelectedIndex}
/>
</Popover>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SlashCommandInput } from "./SlashCommandInput";
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { PopoverContent } from "@superset/ui/popover";
import { useEffect, useRef } from "react";
import type { SlashCommand } from "../../hooks/useSlashCommands";

interface SlashCommandMenuProps {
commands: SlashCommand[];
selectedIndex: number;
onSelect: (command: SlashCommand) => void;
onHover: (index: number) => void;
}

export function SlashCommandMenu({
commands,
selectedIndex,
onSelect,
onHover,
}: SlashCommandMenuProps) {
const selectedRef = useRef<HTMLButtonElement>(null);

// biome-ignore lint/correctness/useExhaustiveDependencies: must scroll when selectedIndex changes
useEffect(() => {
selectedRef.current?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);

if (commands.length === 0) return null;

return (
<PopoverContent
side="top"
align="start"
sideOffset={0}
className="w-80 p-0 text-xs"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="max-h-[200px] overflow-y-auto p-1">
{commands.map((cmd, index) => (
<button
key={cmd.name}
ref={index === selectedIndex ? selectedRef : undefined}
type="button"
className={`flex w-full cursor-pointer flex-col gap-0.5 rounded-md px-3 py-2 text-left transition-colors ${
index === selectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
}`}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(cmd);
}}
>
<div className="flex items-center gap-1.5">
<span className="font-mono text-muted-foreground">/</span>
<span className="font-medium">{cmd.name}</span>
{cmd.argumentHint && (
<span className="text-muted-foreground">
{cmd.argumentHint}
</span>
)}
</div>
{cmd.description && (
<span className="text-muted-foreground pl-4">
{cmd.description}
</span>
)}
</button>
))}
</div>
</PopoverContent>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SlashCommandMenu } from "./SlashCommandMenu";
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
resolveCommandAction,
type SlashCommand,
useSlashCommands,
} from "./useSlashCommands";
Loading
Loading