mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
feat: add Homebrew Cask support and update SHA256 for macOS builds
fix: enhance slash command picker with loading state and tab filtering fix: improve PDF preview button styling and functionality fix: update PDF viewer layout for better responsiveness fix: handle user cancellation in Claude event processing chore: add cancelledByUser state to Claude chat store docs: update README with installation instructions for macOS
This commit is contained in:
parent
6f82e67350
commit
480976c15a
9 changed files with 372 additions and 135 deletions
36
.github/workflows/build-desktop.yml
vendored
36
.github/workflows/build-desktop.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const slashFilteredCountRef = useRef(0);
|
||||
const composerRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
||||
// / 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 (
|
||||
<div className="relative shrink-0 p-3">
|
||||
{/* / slash command picker */}
|
||||
<div ref={composerRef} className="relative shrink-0 p-3">
|
||||
{/* / slash command picker — portal to body to escape all stacking contexts */}
|
||||
{slashQuery !== null && (
|
||||
<SlashCommandPicker
|
||||
projectPath={projectRoot}
|
||||
query={slashQuery}
|
||||
selectedIndex={slashIndex}
|
||||
anchorRef={composerRef}
|
||||
onSelect={selectSlashCommand}
|
||||
onClose={() => {
|
||||
setSlashQuery(null);
|
||||
setSlashIndex(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>;
|
||||
onSelect: (command: SlashCommand) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getCommandIcon(command: SlashCommand) {
|
||||
if (command.has_bash_commands) return <TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.has_file_references) return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.accepts_arguments) return <ZapIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "project") return <FolderOpenIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "user") return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
||||
if (command.has_bash_commands) return <TerminalIcon className="size-4 shrink-0 text-muted-foreground" />;
|
||||
if (command.has_file_references) return <FileCodeIcon className="size-4 shrink-0 text-muted-foreground" />;
|
||||
if (command.accepts_arguments) return <ZapIcon className="size-4 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "project") return <FolderOpenIcon className="size-4 shrink-0 text-muted-foreground" />;
|
||||
if (command.scope === "user") return <GlobeIcon className="size-4 shrink-0 text-muted-foreground" />;
|
||||
return <CommandIcon className="size-4 shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
|
||||
projectPath,
|
||||
query,
|
||||
selectedIndex,
|
||||
anchorRef,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const [commands, setCommands] = useState<SlashCommand[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState<"default" | "custom">("default");
|
||||
const listRef = useRef<HTMLDivElement>(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<SlashCommand[]>("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<SlashCommandPickerProps> = ({
|
|||
});
|
||||
|
||||
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<string, SlashCommand[]>);
|
||||
}, [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 (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute bottom-full left-3 right-3 mb-1 max-h-64 overflow-y-auto rounded-lg border border-border bg-background shadow-lg"
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground">Slash Commands</span>
|
||||
</div>
|
||||
{filtered.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
data-active={i === clampedIndex}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 px-3 py-1.5 text-left transition-colors",
|
||||
i === clampedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
const renderCommandItem = (cmd: SlashCommand, index: number) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={cmd.id}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 px-3 py-2 rounded-md text-left transition-colors",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(cmd);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
{getCommandIcon(cmd)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-sm">{cmd.full_command}</span>
|
||||
{cmd.accepts_arguments && (
|
||||
<span className="text-xs text-muted-foreground">[args]</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{cmd.scope}
|
||||
</span>
|
||||
</div>
|
||||
{cmd.description && (
|
||||
<p className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{cmd.description}
|
||||
</p>
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(cmd);
|
||||
}}
|
||||
>
|
||||
{getCommandIcon(cmd)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{cmd.full_command}</span>
|
||||
{cmd.accepts_arguments && (
|
||||
<span className="text-xs text-muted-foreground">[args]</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{cmd.scope}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{cmd.allowed_tools.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cmd.allowed_tools.length} tool{cmd.allowed_tools.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
{cmd.description && (
|
||||
<p className="truncate text-xs text-muted-foreground">{cmd.description}</p>
|
||||
)}
|
||||
{cmd.has_bash_commands && (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">Bash</span>
|
||||
)}
|
||||
{cmd.has_file_references && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">Files</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-border px-3 py-1">
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed flex flex-col overflow-hidden rounded-lg border border-border bg-background shadow-lg"
|
||||
style={{ left: pos.left, right: pos.right, bottom: pos.bottom, maxHeight: "400px", zIndex: 9999 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Slash Commands</span>
|
||||
{query && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Searching: "{query}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
className="rounded-md p-1 transition-colors hover:bg-muted"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mt-2 flex gap-1">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-center text-xs font-medium transition-colors",
|
||||
activeTab === "default"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setActiveTab("default");
|
||||
}}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-center text-xs font-medium transition-colors",
|
||||
activeTab === "custom"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setActiveTab("custom");
|
||||
}}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command List */}
|
||||
<div className="flex-1 overflow-y-auto" ref={listRef}>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-sm text-muted-foreground">Loading commands...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<SearchIcon className="size-8 text-muted-foreground mb-2" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{query ? "No commands found" : "No commands available"}
|
||||
</span>
|
||||
{!query && activeTab === "custom" && (
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center px-4">
|
||||
Create commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filtered.length > 0 && (
|
||||
<div className="p-2">
|
||||
{activeTab === "default" || Object.keys(groupedCommands).length <= 1 ? (
|
||||
<div className="space-y-0.5">
|
||||
{filtered.map((cmd, i) => renderCommandItem(cmd, i))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedCommands).map(([groupKey, groupCmds]) => (
|
||||
<div key={groupKey}>
|
||||
<h3 className="flex items-center gap-2 px-3 mb-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{groupKey.startsWith("User Commands") && <UserIcon className="size-3" />}
|
||||
{groupKey.startsWith("Project Commands") && <Building2Icon className="size-3" />}
|
||||
{groupKey}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{groupCmds.map((cmd) => {
|
||||
const globalIndex = filtered.indexOf(cmd);
|
||||
return renderCommandItem(cmd, globalIndex);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
↑↓ Navigate · Enter Select · Esc Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -515,13 +515,20 @@ export function PdfPreview() {
|
|||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
{/* Capture mode */}
|
||||
<Button
|
||||
variant={captureMode ? "default" : "ghost"}
|
||||
variant={captureMode ? "default" : "secondary"}
|
||||
size="sm"
|
||||
className={`h-7 gap-1.5 px-2.5 text-xs ${captureMode ? "ring-2 ring-primary/30" : ""}`}
|
||||
className={`h-7 gap-1.5 px-2.5 text-xs ${
|
||||
captureMode
|
||||
? "ring-2 ring-primary/30"
|
||||
: "bg-foreground text-background hover:bg-foreground/90"
|
||||
}`}
|
||||
onClick={() => setCaptureMode(!captureMode)}
|
||||
>
|
||||
<CrosshairIcon className="size-3.5" />
|
||||
Capture
|
||||
Capture & Ask
|
||||
<kbd className="pointer-events-none ml-0.5 rounded border border-background/30 bg-background/20 px-1.5 py-0.5 text-xs font-medium leading-none text-background">
|
||||
{navigator.userAgent.includes("Mac") ? "⌘X" : "Ctrl+X"}
|
||||
</kbd>
|
||||
</Button>
|
||||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2.5 text-xs" onClick={handleExport} title="Export PDF">
|
||||
|
|
|
|||
|
|
@ -556,7 +556,7 @@ export function PdfViewer({
|
|||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex flex-col items-center gap-4 p-4"
|
||||
className="flex min-w-fit flex-col items-center gap-4 p-4"
|
||||
onClick={handleTextLayerClick}
|
||||
>
|
||||
{loading && numPages === 0 && (
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ export function useClaudeEvents() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!success && msgCount > 0 && !chatStore.error && !cancelledForAskRef.current) {
|
||||
if (!success && msgCount > 0 && !chatStore.error && !cancelledForAskRef.current && !chatStore._cancelledByUser) {
|
||||
// Process failed but we received some messages — likely rate limit or API error
|
||||
chatStore._setError("Claude process exited unexpectedly. This may be due to rate limiting or an API error.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ interface ClaudeChatState {
|
|||
_setSessionId: (id: string) => void;
|
||||
_setStreaming: (streaming: boolean) => void;
|
||||
_setError: (error: string | null) => void;
|
||||
_cancelledByUser: boolean;
|
||||
}
|
||||
|
||||
// ─── Store ───
|
||||
|
|
@ -99,6 +100,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
sessionId: null,
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
_cancelledByUser: false,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
|
||||
|
|
@ -177,6 +179,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
messages: [...state.messages, userMessage],
|
||||
isStreaming: true,
|
||||
error: null,
|
||||
_cancelledByUser: false,
|
||||
}));
|
||||
|
||||
// Flush unsaved edits to disk so Claude reads the latest content
|
||||
|
|
@ -250,6 +253,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
},
|
||||
|
||||
cancelExecution: async () => {
|
||||
set({ _cancelledByUser: true });
|
||||
try {
|
||||
await invoke("cancel_claude_execution");
|
||||
} catch {
|
||||
|
|
|
|||
28
homebrew/Casks/claude-prism.rb
Normal file
28
homebrew/Casks/claude-prism.rb
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
cask "claude-prism" do
|
||||
version "0.0.1"
|
||||
|
||||
on_arm do
|
||||
sha256 "PLACEHOLDER_ARM64_SHA256"
|
||||
url "https://github.com/delibae/claude-prism/releases/download/v#{version}/ClaudePrism_#{version}_aarch64.dmg"
|
||||
end
|
||||
|
||||
on_intel do
|
||||
sha256 "PLACEHOLDER_X64_SHA256"
|
||||
url "https://github.com/delibae/claude-prism/releases/download/v#{version}/ClaudePrism_#{version}_x64.dmg"
|
||||
end
|
||||
|
||||
name "ClaudePrism"
|
||||
desc "Desktop app for Claude-powered academic research workflows"
|
||||
homepage "https://github.com/delibae/claude-prism"
|
||||
|
||||
depends_on macos: ">= :big_sur"
|
||||
|
||||
app "ClaudePrism.app"
|
||||
|
||||
zap trash: [
|
||||
"~/Library/Application Support/com.claude-prism.desktop",
|
||||
"~/Library/Caches/com.claude-prism.desktop",
|
||||
"~/Library/Preferences/com.claude-prism.desktop.plist",
|
||||
"~/Library/Saved Application State/com.claude-prism.desktop.savedState",
|
||||
]
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue