diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index eee09e2..aadc4c6 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -205,3 +205,39 @@ jobs: gh release edit "$TAG" \ --repo "${{ github.repository }}" \ --notes-file release-body.md + + - name: Update Homebrew Cask SHA256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + + # Download DMG files + gh release download "$TAG" \ + --repo "${{ github.repository }}" \ + --pattern "*.dmg" \ + --dir /tmp/dmg + + # Calculate SHA256 + SHA_ARM64=$(sha256sum /tmp/dmg/ClaudePrism_${VERSION}_aarch64.dmg | awk '{print $1}') + SHA_X64=$(sha256sum /tmp/dmg/ClaudePrism_${VERSION}_x64.dmg | awk '{print $1}') + + echo "ARM64 SHA256: $SHA_ARM64" + echo "X64 SHA256: $SHA_X64" + + # Update Cask file + CASK_FILE="homebrew/Casks/claude-prism.rb" + sed -i "s/version \".*\"/version \"${VERSION}\"/" "$CASK_FILE" + # Update arm64 SHA256 (first sha256 occurrence) + sed -i "0,/sha256 \".*\"/{s/sha256 \".*\"/sha256 \"${SHA_ARM64}\"/}" "$CASK_FILE" + # Update x64 SHA256 (second sha256 occurrence) + sed -i "0,/sha256 \".*\"/! {0,/sha256 \".*\"/s/sha256 \".*\"/sha256 \"${SHA_X64}\"/}" "$CASK_FILE" + + # Commit and push + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "$CASK_FILE" + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "chore: update Homebrew Cask to ${TAG}" + git push origin HEAD:main diff --git a/README.md b/README.md index b414e5a..9a1f97a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,15 @@ Open-source AI-powered LaTeX writing workspace with live preview. - **Local Storage** - Documents saved in browser IndexedDB - **Dark/Light Theme** - Automatic theme switching support +## Install Desktop App (macOS) + +```bash +brew tap delibae/claude-prism +brew install --cask claude-prism +``` + +Or download `.dmg` directly from [GitHub Releases](https://github.com/delibae/claude-prism/releases). + ## Quick Start ```bash diff --git a/apps/desktop/src/components/claude-chat/chat-composer.tsx b/apps/desktop/src/components/claude-chat/chat-composer.tsx index 809af49..667e0ac 100644 --- a/apps/desktop/src/components/claude-chat/chat-composer.tsx +++ b/apps/desktop/src/components/claude-chat/chat-composer.tsx @@ -12,6 +12,9 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { cn } from "@/lib/utils"; import { SlashCommandPicker, type SlashCommand } from "./slash-command-picker"; +// Re-export for other modules +export type { SlashCommand }; + interface PinnedContext { label: string; // @file:line:col-line:col filePath: string; @@ -68,9 +71,8 @@ export const ChatComposer: FC = () => { // / slash command state const [slashQuery, setSlashQuery] = useState(null); - const [slashIndex, setSlashIndex] = useState(0); const [slashCommands, setSlashCommands] = useState([]); - const slashFilteredCountRef = useRef(0); + const composerRef = useRef(null); // Watch selection changes to auto-pin context const selectionRange = useDocumentStore((s) => s.selectionRange); @@ -152,26 +154,6 @@ export const ChatComposer: FC = () => { .catch(() => setSlashCommands([])); }, [slashQuery !== null, projectRoot]); - // Compute filtered slash command count for keyboard navigation bounds - useEffect(() => { - if (slashQuery === null || slashCommands.length === 0) { - slashFilteredCountRef.current = 0; - return; - } - const q = slashQuery.toLowerCase(); - if (!q) { - slashFilteredCountRef.current = slashCommands.length; - } else { - slashFilteredCountRef.current = slashCommands.filter((cmd) => { - if (cmd.name.toLowerCase().includes(q)) return true; - if (cmd.full_command.toLowerCase().includes(q)) return true; - if (cmd.namespace && cmd.namespace.toLowerCase().includes(q)) return true; - if (cmd.description && cmd.description.toLowerCase().includes(q)) return true; - return false; - }).length; - } - }, [slashQuery, slashCommands]); - const selectMention = useCallback((file: ProjectFile) => { // Replace @query with empty and pin the file as context const textarea = textareaRef.current; @@ -203,14 +185,13 @@ export const ChatComposer: FC = () => { }, [input]); const selectSlashCommand = useCallback((command: SlashCommand) => { - // Insert command text into input (opcode-style: insert as template, don't send immediately) + // Insert command syntax into input (opcode-style) const newInput = command.accepts_arguments ? `${command.full_command} ` - : command.content; + : `${command.full_command} `; setInput(newInput); setSlashQuery(null); - setSlashIndex(0); // Refocus and move cursor to end setTimeout(() => { @@ -432,28 +413,11 @@ export const ChatComposer: FC = () => { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - // / slash command navigation - if (slashQuery !== null && slashFilteredCountRef.current > 0) { - if (e.key === "ArrowDown") { + // Slash command picker is open — let the picker handle keyboard events + // (it uses window.addEventListener for ArrowUp/Down, Enter, Tab, Escape) + if (slashQuery !== null) { + if (e.key === "Enter" || e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab" || e.key === "Escape") { e.preventDefault(); - setSlashIndex((i) => Math.min(i + 1, slashFilteredCountRef.current - 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setSlashIndex((i) => Math.max(i - 1, 0)); - return; - } - if (e.key === "Tab") { - e.preventDefault(); - // Tab selects the command — trigger via a DOM query for the active item - // We'll let the picker handle it via onSelect - return; - } - if (e.key === "Escape") { - e.preventDefault(); - setSlashQuery(null); - setSlashIndex(0); return; } } @@ -505,14 +469,12 @@ export const ChatComposer: FC = () => { const slashMatchWithArgs = value.match(/^\/(\S+)\s/); if (slashMatch) { setSlashQuery(slashMatch[1]); - setSlashIndex(0); setMentionQuery(null); } else if (slashMatchWithArgs) { // Keep slash picker open while typing args after command name setSlashQuery(slashMatchWithArgs[1]); } else if (!value.startsWith("/")) { setSlashQuery(null); - setSlashIndex(0); } // Detect @ mention trigger (only when not in slash command mode) @@ -561,17 +523,16 @@ export const ChatComposer: FC = () => { }, [modelPickerOpen]); return ( -
- {/* / slash command picker */} +
+ {/* / slash command picker — portal to body to escape all stacking contexts */} {slashQuery !== null && ( { setSlashQuery(null); - setSlashIndex(0); }} /> )} diff --git a/apps/desktop/src/components/claude-chat/slash-command-picker.tsx b/apps/desktop/src/components/claude-chat/slash-command-picker.tsx index 8516375..c3713c6 100644 --- a/apps/desktop/src/components/claude-chat/slash-command-picker.tsx +++ b/apps/desktop/src/components/claude-chat/slash-command-picker.tsx @@ -1,6 +1,7 @@ -import { type FC, useEffect, useMemo, useRef, useState } from "react"; +import { type FC, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { invoke } from "@tauri-apps/api/core"; -import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon } from "lucide-react"; +import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon, XIcon, SearchIcon, UserIcon, Building2Icon } from "lucide-react"; import { cn } from "@/lib/utils"; export interface SlashCommand { @@ -21,43 +22,74 @@ export interface SlashCommand { interface SlashCommandPickerProps { projectPath: string | null; query: string; - selectedIndex: number; + anchorRef: RefObject; onSelect: (command: SlashCommand) => void; onClose: () => void; } function getCommandIcon(command: SlashCommand) { - if (command.has_bash_commands) return ; - if (command.has_file_references) return ; - if (command.accepts_arguments) return ; - if (command.scope === "project") return ; - if (command.scope === "user") return ; - return ; + if (command.has_bash_commands) return ; + if (command.has_file_references) return ; + if (command.accepts_arguments) return ; + if (command.scope === "project") return ; + if (command.scope === "user") return ; + return ; } export const SlashCommandPicker: FC = ({ projectPath, query, - selectedIndex, + anchorRef, onSelect, onClose, }) => { const [commands, setCommands] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedIndex, setSelectedIndex] = useState(0); + const [activeTab, setActiveTab] = useState<"default" | "custom">("default"); const listRef = useRef(null); + const [pos, setPos] = useState<{ left: number; right: number; bottom: number }>({ left: 0, right: 0, bottom: 0 }); + // Compute fixed position from anchor element + useLayoutEffect(() => { + if (!anchorRef.current) return; + const rect = anchorRef.current.getBoundingClientRect(); + setPos({ + left: rect.left, + right: window.innerWidth - rect.right, + bottom: window.innerHeight - rect.top + 4, + }); + }, [anchorRef]); + + // Load commands on mount useEffect(() => { + setIsLoading(true); invoke("slash_commands_list", { projectPath: projectPath ?? undefined, }) - .then(setCommands) - .catch(() => setCommands([])); + .then((cmds) => { + setCommands(cmds); + setIsLoading(false); + }) + .catch(() => { + setCommands([]); + setIsLoading(false); + }); }, [projectPath]); + // Filter by tab and query const filtered = useMemo(() => { - const q = query.toLowerCase(); - if (!q) return commands; + let byTab: SlashCommand[]; + if (activeTab === "default") { + byTab = commands.filter((cmd) => cmd.scope === "default"); + } else { + byTab = commands.filter((cmd) => cmd.scope !== "default"); + } - const matched = commands.filter((cmd) => { + const q = query.toLowerCase(); + if (!q) return byTab; + + const matched = byTab.filter((cmd) => { if (cmd.name.toLowerCase().includes(q)) return true; if (cmd.full_command.toLowerCase().includes(q)) return true; if (cmd.namespace && cmd.namespace.toLowerCase().includes(q)) return true; @@ -78,82 +110,242 @@ export const SlashCommandPicker: FC = ({ }); return matched; - }, [query, commands]); + }, [query, commands, activeTab]); - // Scroll active item into view + // Group commands by scope/namespace for the custom tab + const groupedCommands = useMemo(() => { + return filtered.reduce((acc, cmd) => { + let key: string; + if (cmd.scope === "user") { + key = cmd.namespace ? `User Commands: ${cmd.namespace}` : "User Commands"; + } else if (cmd.scope === "project") { + key = cmd.namespace ? `Project Commands: ${cmd.namespace}` : "Project Commands"; + } else { + key = cmd.namespace || "Commands"; + } + if (!acc[key]) acc[key] = []; + acc[key].push(cmd); + return acc; + }, {} as Record); + }, [filtered]); + + // Reset selected index when filtered list changes + useEffect(() => { + setSelectedIndex(0); + }, [query, activeTab]); + + // Keyboard navigation via window listener + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "Escape": + e.preventDefault(); + onClose(); + break; + case "Enter": + e.preventDefault(); + if (filtered.length > 0 && selectedIndex < filtered.length) { + onSelect(filtered[selectedIndex]); + } + break; + case "Tab": + e.preventDefault(); + if (filtered.length > 0 && selectedIndex < filtered.length) { + onSelect(filtered[selectedIndex]); + } + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => Math.max(0, prev - 1)); + break; + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => Math.min(filtered.length - 1, prev + 1)); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [filtered, selectedIndex, onSelect, onClose]); + + // Scroll selected item into view useEffect(() => { if (listRef.current) { - const active = listRef.current.querySelector("[data-active=true]"); - active?.scrollIntoView({ block: "nearest" }); + const selected = listRef.current.querySelector(`[data-index="${selectedIndex}"]`); + selected?.scrollIntoView({ block: "nearest", behavior: "smooth" }); } }, [selectedIndex]); - // Expose filtered list length and items for parent keyboard handling - // Parent controls selectedIndex, so we just need to render - const clampedIndex = Math.min(selectedIndex, filtered.length - 1); - - if (filtered.length === 0) return null; - - return ( -
-
- Slash Commands -
- {filtered.map((cmd, i) => ( - - ))} -
+
+ + ); + }; + + return createPortal( +
+ {/* Header */} +
+
+
+ + Slash Commands + {query && ( + + Searching: "{query}" + + )} +
+ +
+ + {/* Tabs */} +
+ + +
+
+ + {/* Command List */} +
+ {isLoading && ( +
+ Loading commands... +
+ )} + + {!isLoading && filtered.length === 0 && ( +
+ + + {query ? "No commands found" : "No commands available"} + + {!query && activeTab === "custom" && ( +

+ Create commands in .claude/commands/ or ~/.claude/commands/ +

+ )} +
+ )} + + {!isLoading && filtered.length > 0 && ( +
+ {activeTab === "default" || Object.keys(groupedCommands).length <= 1 ? ( +
+ {filtered.map((cmd, i) => renderCommandItem(cmd, i))} +
+ ) : ( +
+ {Object.entries(groupedCommands).map(([groupKey, groupCmds]) => ( +
+

+ {groupKey.startsWith("User Commands") && } + {groupKey.startsWith("Project Commands") && } + {groupKey} +

+
+ {groupCmds.map((cmd) => { + const globalIndex = filtered.indexOf(cmd); + return renderCommandItem(cmd, globalIndex); + })} +
+
+ ))} +
+ )} +
+ )} +
+ + {/* Footer */} +
↑↓ Navigate · Enter Select · Esc Close
-
+
, + document.body, ); }; - -// Helper to get filtered count for parent component -export function getFilteredSlashCommands( - commands: SlashCommand[], - query: string, -): SlashCommand[] { - const q = query.toLowerCase(); - if (!q) return commands; - - return commands.filter((cmd) => { - if (cmd.name.toLowerCase().includes(q)) return true; - if (cmd.full_command.toLowerCase().includes(q)) return true; - if (cmd.namespace && cmd.namespace.toLowerCase().includes(q)) return true; - if (cmd.description && cmd.description.toLowerCase().includes(q)) return true; - return false; - }); -} diff --git a/apps/desktop/src/components/workspace/preview/pdf-preview.tsx b/apps/desktop/src/components/workspace/preview/pdf-preview.tsx index 265a109..af7a8ce 100644 --- a/apps/desktop/src/components/workspace/preview/pdf-preview.tsx +++ b/apps/desktop/src/components/workspace/preview/pdf-preview.tsx @@ -515,13 +515,20 @@ export function PdfPreview() {
{/* Capture mode */}