diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml new file mode 100644 index 0000000..f652ba7 --- /dev/null +++ b/.github/workflows/build-desktop.yml @@ -0,0 +1,160 @@ +name: Build Desktop + +on: + workflow_dispatch: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: windows-latest + target: x86_64-pc-windows-msvc + label: windows + - platform: macos-latest + target: aarch64-apple-darwin + label: macos-arm64 + - platform: macos-13 + target: x86_64-apple-darwin + label: macos-x64 + - platform: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + label: linux-x64 + + runs-on: ${{ matrix.platform }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Linux dependencies + if: matrix.label == 'linux-x64' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: apps/desktop/src-tauri -> target + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Build and release + if: startsWith(github.ref, 'refs/tags/v') + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: apps/desktop + tauriScript: pnpm tauri + args: --target ${{ matrix.target }} + tagName: ${{ github.ref_name }} + releaseName: "ClaudePrism ${{ github.ref_name }}" + releaseBody: "Release is being built..." + releaseDraft: false + prerelease: false + + - name: Build only (no release) + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: apps/desktop + tauriScript: pnpm tauri + args: --target ${{ matrix.target }} + + - name: Upload artifacts (manual builds) + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + uses: actions/upload-artifact@v4 + with: + name: desktop-${{ matrix.label }} + path: | + apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.exe + apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.msi + apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.dmg + apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.app.tar.gz* + apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.deb + apps/desktop/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage* + if-no-files-found: warn + + update-release: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog and update release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF_NAME}" + PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | sed -n '2p' || true) + + # Build changelog + if [ -n "$PREV_TAG" ]; then + CHANGES=$(git log --pretty=format:"- %s" "$PREV_TAG".."$TAG" --no-merges 2>/dev/null || echo "- Initial release") + COMPARE="[Full Changelog](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${TAG})" + else + CHANGES="- Initial release" + COMPARE="" + fi + + cat > release-body.md << EOF +

+ +

+ + ## ClaudePrism ${TAG} + + ### What's Changed + + ${CHANGES} + + ### Downloads + + - **Windows**: \`.msi\` (installer) or \`.exe\` (NSIS setup) + - **macOS**: \`.dmg\` (Apple Silicon: \`aarch64\`, Intel: \`x64\`) + - **Linux**: \`.AppImage\` (universal) or \`.deb\` (Debian/Ubuntu) + + ### Installation + + - **Windows**: Run the \`.msi\` installer or \`.exe\` setup + - **macOS**: Open the \`.dmg\` and drag ClaudePrism to Applications + - **Linux**: \`chmod +x\` the \`.AppImage\` and run it, or \`sudo dpkg -i\` the \`.deb\` + + ${COMPARE} + EOF + + gh release edit "$TAG" \ + --repo "${{ github.repository }}" \ + --notes-file release-body.md diff --git a/.gitignore b/.gitignore index 4006871..f449d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,53 @@ yarn-error.log* # Misc .DS_Store *.pem + +*.acn +*.acr +*.alg +*.aux +*.bak +*.bbl +*.bcf +*.blg +*.brf +*.bst +*.dvi +*.fdb_latexmk +*.fls +*.glg +*.glo +*.gls +*.idx +*.ilg +*.ind +*.ist +*.lof +*.log +*.lol +*.lot +*.maf +*.mtc +*.mtc1 +*.nav +*.nlo +*.nls +*.out +*.pdf +*.pyg +*.run.xml +*.snm +*.synctex.gz +*.tex.backup +*.tex~ +*.thm +*.toc +*.vrb +*.xdy +*.xml +*blx.bib +.bak +.mtc +build/ +reference_pr +reference_pr_claude diff --git a/SHARED_TASK_NOTES.md b/SHARED_TASK_NOTES.md new file mode 100644 index 0000000..0fa25c9 --- /dev/null +++ b/SHARED_TASK_NOTES.md @@ -0,0 +1,47 @@ +# Template Gallery — Shared Task Notes + +## What's Done (Phase 1 + Phase 2 start) + +### Template Data Architecture (Phase 1) +- `src/lib/template-registry.ts` — Template schema with rich metadata: categories, subcategories, tags, packages, accent colors, bibliography flag. 12 templates total (was 8). New: IEEE, ACM, Technical Report, Newsletter. Registry API: `getAllTemplates()`, `getTemplateById()`, `searchTemplates()`, `getTemplatesByCategory()`. +- `src/stores/template-store.ts` — Zustand store for gallery state: search query, category filter, template selection, preview state. `computeFiltered()` handles combined search+category filtering. + +### Visual Gallery UI (Phase 2 start) +- `src/components/template-gallery/` — New component directory: + - `template-gallery.tsx` — Main gallery: search bar (⌘K), category sidebar + grid layout, grouped view when no filter active, empty state + - `template-card.tsx` — Cards with CSS-based document thumbnail placeholders (9 distinct layouts: paper, slides, poster, CV, letter, book, report, newsletter, blank). Hover reveals "Preview" button + - `category-sidebar.tsx` — Category navigation with template counts + - `template-preview.tsx` — Dialog showing LaTeX source, package list, tags, metadata, "Use Template" CTA + - `index.ts` — Barrel export + +### Wizard Refactor +- `project-wizard.tsx` — Now uses `TemplateGallery` for step 1 instead of old inline `TemplateGrid`. Step 2 (project details) unchanged. All project creation logic preserved. Uses `template.hasBibliography` instead of hardcoded ID check. + +## Next Priorities + +### Phase 2 completion +- **Keyboard navigation** — Arrow keys to navigate grid, Enter to select, Tab between sections +- **Animations** — Consider adding `framer-motion` (not currently installed) for card entrance animations and page transitions +- **Responsive grid** — Test and tune grid breakpoints for various window sizes + +### Phase 3: Template Preview System +- **PDF thumbnail generation** — Compile templates to PDF, render first page as image using pdfjs-dist (already a dependency). Store as cached images. Replace CSS placeholders with actual rendered thumbnails +- **Live preview in modal** — Show rendered PDF in the preview dialog instead of just source code +- **Multi-page preview** — For templates like presentations/books +- **Side-by-side compare** — Allow comparing two templates + +### Phase 4: Package Integration +- **Package browser** — Curated CTAN package list +- **Package adding** — Let users add packages before project creation +- **Compatibility checker** — Warn about conflicting packages + +### Phase 5: More Templates +- **Springer, APA, MLA, Chicago** style templates +- **User template library** — Save customized templates +- **Template import** — From .tex files or Overleaf + +## Technical Notes +- No `framer-motion` installed — animations use Tailwind CSS `tw-animate-css`. Install if needed for gallery transitions +- `line-clamp-2` used in template-card.tsx — may need `@tailwindcss/line-clamp` if not natively supported (TW4 may include it) +- Template IDs changed: `paper` → `paper-standard`, `cv` → `cv-modern`, etc. Old IDs no longer exist +- `react-pdf` and `pdfjs-dist` already installed — ready for thumbnail generation work diff --git a/apps/desktop-sidecar/package.json b/apps/desktop-sidecar/package.json deleted file mode 100644 index 3f21fee..0000000 --- a/apps/desktop-sidecar/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@claude-prism/desktop-sidecar", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsup", - "start": "node dist/index.js" - }, - "dependencies": { - "@hono/node-server": "^1.13.8", - "hono": "^4.6.0" - }, - "devDependencies": { - "@types/node": "^22.14.0", - "tsup": "^8.4.0", - "tsx": "^4.19.3", - "typescript": "^5.7.0" - } -} diff --git a/apps/desktop-sidecar/src/compile.ts b/apps/desktop-sidecar/src/compile.ts deleted file mode 100644 index 5e4fa2b..0000000 --- a/apps/desktop-sidecar/src/compile.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { Hono } from "hono"; -import { bodyLimit } from "hono/body-limit"; -import { mkdir, rm, writeFile, readFile, access, cp, realpath } from "node:fs/promises"; -import { join, resolve, basename } from "node:path"; -import { tmpdir } from "node:os"; -import { randomUUID } from "node:crypto"; -import { spawn } from "node:child_process"; - -const MAX_CONCURRENT = 3; -const COMPILE_TIMEOUT_MS = 30000; - -let activeCompilations = 0; - -// Track last build dir per project for synctex inverse search -const lastBuildDirs = new Map(); - -function sanitizePath(workDir: string, filePath: string): string | null { - if (filePath.includes("..")) return null; - const normalized = resolve(workDir, filePath); - if (!normalized.startsWith(`${workDir}/`) && normalized !== workDir) { - return null; - } - return normalized; -} - -interface Resource { - path?: string; - content?: string; - file?: string; - main?: boolean; -} - -interface CompileRequest { - compiler?: string; - resources?: Resource[]; - projectDir?: string; - mainFile?: string; -} - -interface CompileError { - error: string; - details?: string; - log_files?: Record; -} - -const runWithTimeout = ( - cmd: string[], - cwd: string, -): Promise<{ exitCode: number; timedOut: boolean; stdout: string; stderr: string }> => { - return new Promise((resolve) => { - const [command, ...args] = cmd; - const proc = spawn(command, args, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - }); - let timedOut = false; - let stdout = ""; - let stderr = ""; - proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); }); - proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); }); - const timeout = setTimeout(() => { - timedOut = true; - proc.kill(); - }, COMPILE_TIMEOUT_MS); - proc.on("close", (code) => { - clearTimeout(timeout); - resolve({ exitCode: code ?? 1, timedOut, stdout, stderr }); - }); - proc.on("error", (err) => { - clearTimeout(timeout); - resolve({ exitCode: 1, timedOut: false, stdout, stderr: stderr || err.message }); - }); - }); -}; - -export const compileRoutes = new Hono(); - -compileRoutes.use("/builds/*", bodyLimit({ maxSize: 10 * 1024 * 1024 })); - -compileRoutes.get("/", (c) => { - return c.json({ status: "ok", service: "claude-prism-sidecar" }); -}); - -compileRoutes.post("/builds/sync", async (c) => { - if (activeCompilations >= MAX_CONCURRENT) { - return c.json( - { error: "Server busy, try again later" } satisfies CompileError, - 503, - ); - } - - const body = await c.req.json(); - const { compiler = "pdflatex", resources, projectDir, mainFile } = body; - - // Mode 1: Project directory mode (desktop app) - if (projectDir) { - return handleProjectDirCompile(c, compiler, projectDir, mainFile || "document.tex"); - } - - // Mode 2: Resource upload mode (legacy/web compatibility) - if (!resources || resources.length === 0) { - return c.json( - { error: "No resources or projectDir provided" } satisfies CompileError, - 400, - ); - } - - return handleResourceCompile(c, compiler, resources); -}); - -async function handleProjectDirCompile( - c: any, - compiler: string, - projectDir: string, - mainFile: string, -) { - // Use realpath to resolve symlinks (macOS: /var → /private/var) - // so the stored workDir matches paths returned by synctex - const rawWorkDir = join(tmpdir(), `latex-${randomUUID()}`); - await mkdir(rawWorkDir, { recursive: true }); - const workDir = await realpath(rawWorkDir); - - // Clean up previous build dir for this project - const prevBuild = lastBuildDirs.get(projectDir); - if (prevBuild) { - await rm(prevBuild.workDir, { recursive: true, force: true }).catch(() => {}); - lastBuildDirs.delete(projectDir); - } - - // pdflatex outputs to CWD using basename (e.g. "chapters/doc.tex" → "doc.pdf") - const mainFileName = basename(mainFile).replace(/\.tex$/, ""); - - activeCompilations++; - try { - // Copy project to temp dir to avoid polluting with aux files - await cp(projectDir, workDir, { recursive: true }); - - const compilerCmd = - compiler === "xelatex" - ? "xelatex" - : compiler === "lualatex" - ? "lualatex" - : "pdflatex"; - - // Check for .bib files - const { readdir } = await import("node:fs/promises"); - const files = await readdir(workDir, { recursive: true }); - const hasBib = files.some((f: string) => f.endsWith(".bib")); - - const latexCmd = [compilerCmd, "-interaction=nonstopmode", "-synctex=1", mainFile]; - let lastResult = { exitCode: 0, timedOut: false, stdout: "", stderr: "" }; - - if (hasBib) { - lastResult = await runWithTimeout(latexCmd, workDir); - if (lastResult.timedOut) { - return c.json({ error: "Compilation timed out" } satisfies CompileError, 500); - } - - const auxPath = join(workDir, `${mainFileName}.aux`); - const auxExists = await access(auxPath).then(() => true).catch(() => false); - if (auxExists) { - lastResult = await runWithTimeout(["bibtex", mainFileName], workDir); - if (lastResult.timedOut) { - return c.json({ error: "BibTeX timed out" } satisfies CompileError, 500); - } - } - - for (let i = 0; i < 2; i++) { - lastResult = await runWithTimeout(latexCmd, workDir); - if (lastResult.timedOut) { - return c.json({ error: "Compilation timed out" } satisfies CompileError, 500); - } - } - } else { - lastResult = await runWithTimeout(latexCmd, workDir); - if (lastResult.timedOut) { - return c.json({ error: "Compilation timed out" } satisfies CompileError, 500); - } - } - - let pdfPath = join(workDir, `${mainFileName}.pdf`); - const logPath = join(workDir, `${mainFileName}.log`); - - let logContent = ""; - try { - logContent = await readFile(logPath, "utf-8"); - } catch {} - - // If "No pages of output", retry with \null injected to force an empty page - const pdfExists = await access(pdfPath).then(() => true).catch(() => false); - if (!pdfExists && logContent.includes("No pages of output")) { - const retryCmd = [ - compilerCmd, - "-interaction=nonstopmode", - `-jobname=${mainFileName}`, - `\\AtEndDocument{\\null}\\input{${mainFile}}`, - ]; - lastResult = await runWithTimeout(retryCmd, workDir); - try { - logContent = await readFile(logPath, "utf-8"); - } catch {} - } - - try { - const pdfBuffer = await readFile(pdfPath); - return new Response(pdfBuffer, { - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `inline; filename=${mainFileName}.pdf`, - }, - }); - } catch { - const details = extractErrorLines(logContent) - || lastResult.stderr - || lastResult.stdout.slice(-500) - || "No PDF produced. Check that pdflatex is installed."; - return c.json( - { - error: "Compilation failed", - details, - } satisfies CompileError, - 500, - ); - } - } finally { - activeCompilations--; - // Keep workDir alive for synctex inverse search - lastBuildDirs.set(projectDir, { workDir, mainFileName }); - } -} - -function extractErrorLines(log: string): string { - if (!log) return ""; - const lines = log.split("\n"); - - // Check for "No pages of output" (comment-only or empty document body) - if (lines.some((l) => l.includes("No pages of output"))) { - return "No pages of output. Add visible content to the document body."; - } - - const errorLines = lines.filter( - (l) => l.startsWith("!") || l.includes("Error:") || l.includes("error:"), - ); - return errorLines.slice(0, 10).join("\n") || log.slice(-500); -} - -async function handleResourceCompile( - c: any, - compiler: string, - resources: Resource[], -) { - const mainResource = resources.find((r) => r.main) || resources[0]; - const mainPath = mainResource.path || "main.tex"; - const mainFileName = mainPath.replace(/\.tex$/, ""); - - const workDir = join(tmpdir(), `latex-${randomUUID()}`); - await mkdir(workDir, { recursive: true }); - - activeCompilations++; - try { - const hasBib = resources.some((r) => r.path?.endsWith(".bib")); - - for (const resource of resources) { - const filePath = - resource.path || (resource.main ? "main.tex" : `file-${randomUUID()}`); - const fullPath = sanitizePath(workDir, filePath); - - if (!fullPath) { - return c.json({ error: "Invalid path" } satisfies CompileError, 400); - } - - const parentDir = fullPath.substring(0, fullPath.lastIndexOf("/")); - if (parentDir && parentDir !== workDir) { - await mkdir(parentDir, { recursive: true }); - } - - if (resource.file) { - const buffer = Buffer.from(resource.file, "base64"); - await writeFile(fullPath, buffer); - } else if (resource.content) { - await writeFile(fullPath, resource.content, "utf-8"); - } - } - - const compilerCmd = - compiler === "xelatex" - ? "xelatex" - : compiler === "lualatex" - ? "lualatex" - : "pdflatex"; - - const latexCmd = [compilerCmd, "-interaction=nonstopmode", mainPath]; - - if (hasBib) { - let result = await runWithTimeout(latexCmd, workDir); - if (result.timedOut) { - return c.json({ error: "Compilation timed out" } satisfies CompileError, 500); - } - - const auxPath = join(workDir, `${mainFileName}.aux`); - const auxExists = await access(auxPath).then(() => true).catch(() => false); - if (auxExists) { - result = await runWithTimeout(["bibtex", mainFileName], workDir); - if (result.timedOut) { - return c.json({ error: "BibTeX timed out" } satisfies CompileError, 500); - } - } - - for (let i = 0; i < 2; i++) { - result = await runWithTimeout(latexCmd, workDir); - if (result.timedOut) { - return c.json({ error: "Compilation timed out" } satisfies CompileError, 500); - } - } - } else { - for (let i = 0; i < 2; i++) { - const result = await runWithTimeout(latexCmd, workDir); - if (result.timedOut) { - return c.json({ error: "Compilation timed out" } satisfies CompileError, 500); - } - } - } - - const pdfPath = join(workDir, `${mainFileName}.pdf`); - const logPath = join(workDir, `${mainFileName}.log`); - - let logContent = ""; - try { - logContent = await readFile(logPath, "utf-8"); - } catch {} - - try { - const pdfBuffer = await readFile(pdfPath); - return new Response(pdfBuffer, { - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `inline; filename=${mainFileName}.pdf`, - }, - }); - } catch { - return c.json( - { - error: "Compilation failed", - details: extractErrorLines(logContent), - log_files: { "__main_document__.log": logContent }, - } satisfies CompileError, - 500, - ); - } - } finally { - activeCompilations--; - await rm(workDir, { recursive: true, force: true }).catch(() => {}); - } -} - -// SyncTeX inverse search: PDF coordinates → source file + line -// Reference: Overleaf CLSI SynctexOutputParser + CompileManager -compileRoutes.post("/synctex/edit", async (c) => { - const { projectDir, page, x, y } = await c.req.json<{ - projectDir: string; - page: number; - x: number; - y: number; - }>(); - - const build = lastBuildDirs.get(projectDir); - if (!build) { - return c.json({ error: "No build found for this project" }, 404); - } - - // Verify that synctex data was generated - const synctexGz = join(build.workDir, `${build.mainFileName}.synctex.gz`); - const synctexPlain = join(build.workDir, `${build.mainFileName}.synctex`); - const hasSynctex = await access(synctexGz).then(() => true).catch(() => - access(synctexPlain).then(() => true).catch(() => false) - ); - if (!hasSynctex) { - return c.json({ error: "No synctex data found. Recompile with synctex enabled." }, 404); - } - - const pdfFile = `${build.mainFileName}.pdf`; - const result = await runWithTimeout( - ["synctex", "edit", "-o", `${page}:${x}:${y}:${pdfFile}`], - build.workDir, - ); - - if (result.exitCode !== 0) { - console.error("[synctex] edit failed:", result.stderr, result.stdout); - return c.json({ error: "synctex failed", details: result.stderr || result.stdout }, 500); - } - - // Parse synctex output (Overleaf-style): - // SyncTeX result begin - // Output:main.pdf - // Input:./main.tex - // Line:42 - // Column:0 - // ... - // SyncTeX result end - const outputLines = result.stdout.split("\n"); - let file = ""; - let line = 0; - let column = 0; - - for (const l of outputLines) { - const trimmed = l.trim(); - if (trimmed.startsWith("Input:")) file = trimmed.slice("Input:".length); - else if (trimmed.startsWith("Line:")) line = parseInt(trimmed.slice("Line:".length), 10); - else if (trimmed.startsWith("Column:")) column = Math.max(0, parseInt(trimmed.slice("Column:".length), 10)); - } - - if (!file || line === 0) { - console.error("[synctex] could not parse result:", result.stdout); - return c.json({ error: "Could not resolve source location", raw: result.stdout }, 404); - } - - // Normalize file path to relative path within project: - // synctex may return "./main.tex", absolute "/tmp/.../main.tex", or just "main.tex" - let relativeFile = file; - if (relativeFile.startsWith(build.workDir + "/")) { - relativeFile = relativeFile.slice(build.workDir.length + 1); - } - if (relativeFile.startsWith("./")) { - relativeFile = relativeFile.slice(2); - } - - return c.json({ file: relativeFile, line, column }); -}); diff --git a/apps/desktop-sidecar/src/index.ts b/apps/desktop-sidecar/src/index.ts deleted file mode 100644 index 02857cf..0000000 --- a/apps/desktop-sidecar/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { createServer } from "node:net"; -import { compileRoutes } from "./compile.js"; - -const app = new Hono(); - -app.use("/*", cors()); - -// Mount routes (chat is now handled directly via Claude CLI in Rust) -app.route("/", compileRoutes); - -const port = parseInt(process.env.PORT || "3001", 10); - -// Check if port is already in use before starting -function isPortFree(p: number): Promise { - return new Promise((resolve) => { - const server = createServer(); - server.once("error", () => resolve(false)); - server.once("listening", () => { - server.close(); - resolve(true); - }); - server.listen(p); - }); -} - -async function main() { - const free = await isPortFree(port); - if (!free) { - console.log(`Port ${port} already in use, sidecar likely already running`); - process.exit(0); - } - - serve({ - fetch: app.fetch, - port, - }); - - console.log(`ClaudePrism sidecar running on port ${port}`); -} - -main(); diff --git a/apps/desktop-sidecar/tsconfig.json b/apps/desktop-sidecar/tsconfig.json deleted file mode 100644 index 586e551..0000000 --- a/apps/desktop-sidecar/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true, - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/apps/desktop-sidecar/tsup.config.ts b/apps/desktop-sidecar/tsup.config.ts deleted file mode 100644 index da7b674..0000000 --- a/apps/desktop-sidecar/tsup.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm"], - target: "node20", - outDir: "dist", - clean: true, - splitting: false, -}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 782c0e8..8dae17f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "generate-previews": "tsx scripts/generate-previews.ts" }, "dependencies": { "@codemirror/commands": "^6.10.1", @@ -34,13 +35,12 @@ "date-fns": "^4.1.0", "katex": "^0.16.28", "lucide-react": "^0.563.0", + "mupdf": "^1.27.0", "next-themes": "^0.4.6", - "pdfjs-dist": "5.4.296", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^9.0.1", - "react-pdf": "^10.3.0", "react-resizable-panels": "^3.0.6", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", diff --git a/apps/desktop/public/examples/blank/main.tex b/apps/desktop/public/examples/blank/main.tex new file mode 100644 index 0000000..56d2f03 --- /dev/null +++ b/apps/desktop/public/examples/blank/main.tex @@ -0,0 +1,10 @@ +\documentclass[12pt]{article} +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} + +\begin{document} +\null + +% Start writing here. + +\end{document} diff --git a/apps/desktop/public/examples/book-standard/main.tex b/apps/desktop/public/examples/book-standard/main.tex new file mode 100644 index 0000000..d29d507 --- /dev/null +++ b/apps/desktop/public/examples/book-standard/main.tex @@ -0,0 +1,27 @@ +\documentclass[12pt,a4paper]{book} +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{amsmath,amssymb} +\usepackage{graphicx} +\usepackage[margin=1in]{geometry} +\usepackage{hyperref} + +\title{Book Title} +\author{Author Name} +\date{\today} + +\begin{document} + +\frontmatter +\maketitle +\tableofcontents + +\mainmatter + +\chapter{First Chapter} + +\chapter{Second Chapter} + +\backmatter + +\end{document} diff --git a/apps/desktop/public/examples/cv-modern/main.tex b/apps/desktop/public/examples/cv-modern/main.tex new file mode 100644 index 0000000..dcc07cf --- /dev/null +++ b/apps/desktop/public/examples/cv-modern/main.tex @@ -0,0 +1,164 @@ +\documentclass[11pt,a4paper]{article} +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{lmodern} +\usepackage[margin=0.65in]{geometry} +\usepackage{hyperref} +\usepackage{enumitem} +\usepackage{titlesec} +\usepackage{xcolor} +\usepackage{tabularx} +\usepackage{fontawesome5} +\usepackage{microtype} +\usepackage{multicol} + +\definecolor{primary}{HTML}{2C3E50} +\definecolor{accent}{HTML}{2E86C1} +\definecolor{lightgray}{HTML}{95A5A6} +\definecolor{darktext}{HTML}{333333} + +\hypersetup{ + colorlinks=true, + linkcolor=accent, + urlcolor=accent +} + +\titleformat{\section}{\large\bfseries\color{primary}}{}{0em}{}[{\color{accent}\titlerule[1.5pt]}] +\titlespacing{\section}{0pt}{14pt}{6pt} + +\pagestyle{empty} +\setlength{\parindent}{0pt} + +% Custom commands +\newcommand{\cventry}[4]{% + \textbf{#1} \hfill {\color{lightgray}#2}\\ + {\color{accent}\textit{#3}} \hfill {\color{lightgray}\textit{#4}}\\[2pt] +} +\newcommand{\cvsep}{\vspace{6pt}} + +\begin{document} + +% ──── Header ──── +\begin{center} + {\fontsize{28}{34}\selectfont\bfseries\color{primary} Dr.\\ Alexandra Chen}\\[8pt] + {\color{lightgray}\rule{8cm}{0.5pt}}\\[8pt] + {\small + \faIcon{envelope}\;\href{mailto:a.chen@stanford.edu}{a.chen@stanford.edu} \quad + \faIcon{phone}\;+1 (650) 555-0142 \quad + \faIcon{map-marker-alt}\;Stanford, CA\\[4pt] + \faIcon{globe}\;\href{https://alexchen.io}{alexchen.io} \quad + \faIcon{github}\;\href{https://github.com/alexchen}{github.com/alexchen} \quad + \faIcon{linkedin}\;\href{https://linkedin.com/in/alexchen}{linkedin.com/in/alexchen} \quad + \faIcon{graduation-cap}\;\href{https://scholar.google.com/citations?user=XXXXX}{Google Scholar} + } +\end{center} + +\vspace{4pt} + +% ──── Summary ──── +\section{Summary} + +Machine learning researcher with 8+ years of experience in natural language processing and large language models. Published 25+ papers at top venues (NeurIPS, ICML, ACL, EMNLP) with 3,800+ citations. Led research teams developing foundation models deployed to millions of users. Seeking faculty positions in computer science. + +% ──── Education ──── +\section{Education} + +\cventry{Ph.D.\\ in Computer Science}{2016 -- 2021}{Stanford University}{Stanford, CA} +\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt] + \item Thesis: ``Efficient Transformers for Long-Range Sequence Modeling'' + \item Advisor: Prof.\\ Christopher Manning \quad Committee: Profs.\\ Percy Liang, Dan Jurafsky + \item Stanford Graduate Fellowship (SGF) recipient +\end{itemize} + +\cvsep + +\cventry{M.S.\\ in Computer Science}{2014 -- 2016}{Carnegie Mellon University}{Pittsburgh, PA} +\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt] + \item Focus: Machine Learning and Language Technologies + \item GPA: 4.0/4.0 \quad Dean's List all semesters +\end{itemize} + +\cvsep + +\cventry{B.S.\\ in Mathematics and Computer Science}{2010 -- 2014}{MIT}{Cambridge, MA} +\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt] + \item Summa Cum Laude \quad Phi Beta Kappa + \item Undergraduate thesis on spectral graph theory (awarded departmental prize) +\end{itemize} + +% ──── Experience ──── +\section{Professional Experience} + +\cventry{Senior Research Scientist}{2023 -- Present}{Google DeepMind}{Mountain View, CA} +\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt] + \item Lead a team of 8 researchers developing next-generation language models + \item Designed novel attention mechanisms reducing compute by 40\% at scale + \item Core contributor to Gemini model family; responsible for efficiency improvements + \item Mentored 4 PhD interns, all placed at top research labs or faculty positions +\end{itemize} + +\cvsep + +\cventry{Research Scientist}{2021 -- 2023}{Meta AI (FAIR)}{Menlo Park, CA} +\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt] + \item Developed sparse mixture-of-experts models for efficient inference + \item Led the retrieval-augmented generation (RAG) project for factual consistency + \item Published 8 papers; 2 received spotlight/oral designations at NeurIPS + \item Open-sourced model checkpoints downloaded 500K+ times +\end{itemize} + +\cvsep + +\cventry{Research Intern}{Summers 2018, 2019}{OpenAI}{San Francisco, CA} +\begin{itemize}[leftmargin=1.5em, nosep, itemsep=2pt] + \item Contributed to GPT-2 training infrastructure and evaluation framework + \item Developed techniques for reducing hallucination in text generation +\end{itemize} + +% ──── Selected Publications ──── +\section{Selected Publications} +{\small +\begin{enumerate}[leftmargin=1.5em, itemsep=3pt] + \item \textbf{A.\\ Chen}, J.\\ Smith, R.\\ Patel. ``FlashLinear: Sub-Quadratic Attention via Structured State Spaces.'' \textit{NeurIPS 2024}. \textbf{(Oral, top 0.5\%)} + \item \textbf{A.\\ Chen}, M.\\ Johnson. ``Scaling Laws for Mixture-of-Experts Language Models.'' \textit{ICML 2024}. + \item L.\\ Wang, \textbf{A.\\ Chen}, K.\\ Lee. ``Retrieval-Augmented Generation with Learned Relevance.'' \textit{ACL 2023}. \textbf{(Best Paper Award)} + \item \textbf{A.\\ Chen}, T.\\ Brown, S.\\ Narang. ``Efficient Long-Context Transformers via Sliding Window Attention.'' \textit{NeurIPS 2022}. \textbf{(Spotlight)} + \item \textbf{A.\\ Chen}, C.\\ Manning. ``Linear Transformers with Gated Retention.'' \textit{ICLR 2021}. +\end{enumerate} +} + +% ──── Awards ──── +\section{Awards \& Honors} + +\begin{multicols}{2} +\begin{itemize}[leftmargin=1.5em, nosep, itemsep=3pt] + \item ACL Best Paper Award, 2023 + \item MIT Technology Review 35 Under 35, 2024 + \item Stanford Graduate Fellowship, 2016--2021 + \item NSF Graduate Research Fellowship, 2016 + \item CRA Outstanding Undergraduate Award, 2014 + \item Phi Beta Kappa, MIT Chapter, 2014 +\end{itemize} +\end{multicols} + +% ──── Skills ──── +\section{Technical Skills} + +\begin{tabularx}{\textwidth}{@{}lX@{}} +\textbf{Languages:} & Python, C++, CUDA, Julia, Rust \\ +\textbf{Frameworks:} & PyTorch, JAX, TensorFlow, Hugging Face, DeepSpeed, Megatron-LM \\ +\textbf{Infrastructure:} & Kubernetes, Slurm, TPU Pods, multi-node GPU training (up to 2048 GPUs) \\ +\textbf{Specialties:} & Large language models, efficient attention, distributed training, NLP \\ +\end{tabularx} + +% ──── Service ──── +\section{Academic Service} + +\begin{itemize}[leftmargin=1.5em, nosep, itemsep=3pt] + \item \textbf{Area Chair}: NeurIPS 2024, ICML 2024, ACL 2023 + \item \textbf{Reviewer}: NeurIPS, ICML, ICLR, ACL, EMNLP, JMLR, TACL (100+ reviews total) + \item \textbf{Workshop Organizer}: ``Efficient NLP'' at EMNLP 2023; ``Scaling Laws'' at ICML 2024 + \item \textbf{Invited Talks}: Google, Microsoft, Meta, Amazon, Apple, NVIDIA, 15+ universities +\end{itemize} + +\end{document} diff --git a/apps/desktop/public/examples/letter-formal/main.tex b/apps/desktop/public/examples/letter-formal/main.tex new file mode 100644 index 0000000..6271f6e --- /dev/null +++ b/apps/desktop/public/examples/letter-formal/main.tex @@ -0,0 +1,109 @@ +\documentclass[12pt,a4paper]{article} +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{lmodern} +\usepackage[margin=1in]{geometry} +\usepackage{hyperref} +\usepackage{xcolor} +\usepackage{enumitem} +\usepackage{parskip} +\usepackage{microtype} +\usepackage{fancyhdr} +\usepackage{lastpage} +\usepackage{graphicx} + +\definecolor{headercolor}{HTML}{1B3A5C} +\definecolor{accentcolor}{HTML}{2E86C1} +\definecolor{rulecolor}{HTML}{BDC3C7} + +\hypersetup{ + colorlinks=true, + urlcolor=accentcolor +} + +\pagestyle{fancy} +\fancyhf{} +\renewcommand{\headrulewidth}{0pt} +\fancyfoot[C]{\small\color{rulecolor}Page \thepage\\ of \pageref{LastPage}} + +\setlength{\parskip}{10pt} +\setlength{\parindent}{0pt} + +\begin{document} + +% ──── Letterhead ──── +\begin{center} +{\color{headercolor}\rule{\textwidth}{2pt}}\\[12pt] +{\fontsize{20}{24}\selectfont\bfseries\color{headercolor} Quantum Computing Research Laboratory}\\[4pt] +{\large Department of Physics --- Massachusetts Institute of Technology}\\[4pt] +{\small 77 Massachusetts Avenue, Building 26-251, Cambridge, MA 02139}\\[2pt] +{\small Tel: +1 (617) 253-4000 \quad|\quad Fax: +1 (617) 253-8000 \quad|\quad \href{mailto:qcrl@mit.edu}{qcrl@mit.edu}}\\[8pt] +{\color{headercolor}\rule{\textwidth}{0.5pt}} +\end{center} + +\vspace{1cm} + +% ──── Date and Reference ──── +\begin{flushright} +\textbf{Date:} January 15, 2025\\ +\textbf{Ref:} QCRL/2025/REC-0047 +\end{flushright} + +\vspace{0.5cm} + +% ──── Recipient ──── +Prof.\\ Sarah M.\\ Thompson\\ +Chair, Department of Computer Science\\ +University of California, Berkeley\\ +Soda Hall, Room 593\\ +Berkeley, CA 94720 + +\vspace{0.5cm} + +\textbf{Re: Letter of Recommendation for Dr.\\ James Liu -- Assistant Professor Position} + +\vspace{0.3cm} + +Dear Professor Thompson, + +I am writing to provide my strongest recommendation for Dr.\\ James Liu, who is applying for the tenure-track Assistant Professor position in Quantum Computing at the University of California, Berkeley. I have had the privilege of working closely with James for the past five years, first as his doctoral advisor at MIT and subsequently as a research collaborator, and I can state without reservation that he is among the most talented young researchers I have encountered in my thirty-year career. + +James joined my research group in 2018 after completing his undergraduate degree in Physics and Mathematics at Caltech, where he graduated first in his class. From the outset, it was clear that James possessed an unusual combination of deep mathematical intuition, strong programming skills, and creative thinking. His doctoral research focused on developing novel quantum error correction codes for fault-tolerant quantum computation, a topic of immense theoretical and practical importance. + +During his time in my lab, James made several groundbreaking contributions that have significantly advanced the field: + +\begin{enumerate}[leftmargin=2em, itemsep=4pt] +\item \textbf{Topological Error Correction:} James developed a new family of topological quantum error correcting codes that achieve a 3$\times$ improvement in the error threshold compared to the standard surface code. This work, published in \textit{Nature Physics} (2021), has been cited over 200 times and has been experimentally validated by research groups at Google and IBM. + +\item \textbf{Efficient Decoding Algorithms:} He designed a near-linear-time decoding algorithm for his topological codes that runs in real-time on classical hardware, overcoming one of the major practical obstacles to fault-tolerant quantum computing. This work appeared in \textit{Physical Review Letters} (2022) and received the APS Outstanding Doctoral Thesis Award. + +\item \textbf{Noise-Adapted Codes:} Most recently, James pioneered an approach to constructing quantum error correcting codes that are optimized for the specific noise characteristics of a given quantum processor. This noise-aware approach, published in \textit{Science} (2024), represents a paradigm shift in how the community thinks about error correction. +\end{enumerate} + +Beyond his technical accomplishments, James is an exceptional communicator and mentor. He has supervised three master's students and two undergraduate researchers, all of whom have gone on to pursue doctoral studies at leading institutions. His lectures in our graduate quantum information course consistently receive the highest evaluations, and he has a remarkable ability to make complex concepts accessible without sacrificing rigor. + +James's research program is both visionary and practical. He has articulated a compelling five-year plan that bridges the gap between theoretical error correction and the engineering reality of near-term quantum devices. His proposed work on hardware-efficient codes and real-time decoding architectures addresses critical challenges that must be solved for quantum computing to achieve its transformative potential. I believe this research agenda will attract significant funding and top graduate students. + +On a personal level, James is a person of exceptional integrity and collegiality. He is generous with his time, always willing to help colleagues, and has been instrumental in fostering a collaborative and inclusive research culture in our group. He actively participates in outreach activities, including organizing quantum computing workshops for underrepresented high school students in the Boston area. + +In summary, I give Dr.\\ James Liu my highest and most enthusiastic recommendation. He has the intellectual depth, technical skill, creative vision, and personal qualities to become a leading figure in quantum computing and an outstanding faculty member. I am confident that he would be an exceptional addition to your department, and I strongly urge you to give his application the most serious consideration. + +Please do not hesitate to contact me if you require any additional information. I am happy to discuss James's qualifications in further detail by phone or video call at your convenience. + +\vspace{0.8cm} + +Sincerely yours, + +\vspace{1.2cm} + +\textbf{Prof.\\ Richard A.\\ Yamamoto}\\ +William A.\\ Coolidge Professor of Physics\\ +Director, Quantum Computing Research Laboratory\\ +Massachusetts Institute of Technology\\ +\href{mailto:ryamamoto@mit.edu}{ryamamoto@mit.edu} \quad|\quad +1 (617) 253-4851 + +\vspace{1cm} + +{\small\color{rulecolor}\textit{Enclosures: Curriculum Vitae of Dr.\\ James Liu; List of publications}} + +\end{document} diff --git a/apps/desktop/public/examples/newsletter/main.tex b/apps/desktop/public/examples/newsletter/main.tex new file mode 100644 index 0000000..e224032 --- /dev/null +++ b/apps/desktop/public/examples/newsletter/main.tex @@ -0,0 +1,217 @@ +\documentclass[10pt,a4paper]{article} +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{lmodern} +\usepackage[margin=0.6in]{geometry} +\usepackage{multicol} +\usepackage{graphicx} +\usepackage{xcolor} +\usepackage{titlesec} +\usepackage{fancyhdr} +\usepackage{tikz} +\usepackage{enumitem} +\usepackage{microtype} +\usepackage{tabularx} +\usepackage{booktabs} +\usepackage{hyperref} + +% ──── Color Theme ──── +\definecolor{primary}{HTML}{1B3A5C} +\definecolor{accent}{HTML}{E74C3C} +\definecolor{secondary}{HTML}{2E86C1} +\definecolor{lightbg}{HTML}{F8F9FA} +\definecolor{darktext}{HTML}{2C3E50} +\definecolor{rulecolor}{HTML}{BDC3C7} + +\hypersetup{colorlinks=true,urlcolor=secondary,linkcolor=primary} + +% ──── Section Styling ──── +\titleformat{\section}{\Large\bfseries\color{primary}}{}{0em}{}[{\color{accent}\titlerule[1.5pt]}] +\titleformat{\subsection}{\large\bfseries\color{secondary}}{}{0em}{} +\titlespacing{\section}{0pt}{14pt}{6pt} +\titlespacing{\subsection}{0pt}{10pt}{4pt} + +% ──── Header/Footer ──── +\pagestyle{fancy} +\fancyhf{} +\renewcommand{\headrulewidth}{0pt} +\fancyfoot[L]{\small\color{rulecolor}\textit{CS Department Newsletter}} +\fancyfoot[C]{\small\color{rulecolor}\thepage} +\fancyfoot[R]{\small\color{rulecolor}\textit{Spring 2025}} + +\setlength{\parindent}{0pt} +\setlength{\parskip}{6pt} +\setlength{\columnsep}{0.8cm} +\setlength{\columnseprule}{0.4pt} +\renewcommand{\columnseprulecolor}{\color{rulecolor}} + +\begin{document} + +% ════════════════════════════════════════════ +% MASTHEAD +% ════════════════════════════════════════════ +\begin{tikzpicture}[remember picture, overlay] + \fill[primary] ([yshift=0cm]current page.north west) rectangle ([yshift=-4.5cm, xshift=\paperwidth]current page.north west); +\end{tikzpicture} + +\vspace*{-1.5cm} +\begin{center} + {\color{white}\fontsize{36}{44}\selectfont\bfseries The Computing Chronicle}\\[6pt] + {\color{white!80}\large Department of Computer Science --- Stanford University}\\[4pt] + {\color{accent}\rule{6cm}{2pt}}\\[4pt] + {\color{white!70}\small Volume 12, Issue 3 \quad|\quad Spring 2025 \quad|\quad Editor: Prof.\\ Maria Santos} +\end{center} + +\vspace{0.8cm} + +% ════════════════════════════════════════════ +% HIGHLIGHTS BAR +% ════════════════════════════════════════════ +\colorbox{lightbg}{% +\begin{minipage}{\dimexpr\textwidth-2\fboxsep} +\centering\small\color{darktext} +\textbf{IN THIS ISSUE:}\quad +New AI Research Lab Opening $\bullet$ +Faculty Spotlight: Prof.\\ Kim $\bullet$ +Student Hackathon Results $\bullet$ +Alumni Interview $\bullet$ +Upcoming Events +\end{minipage}} + +\vspace{0.6cm} + +% ════════════════════════════════════════════ +% MAIN CONTENT -- Two Columns +% ════════════════════════════════════════════ +\begin{multicols}{2} + +% ──── Lead Story ──── +\section{Department Launches Interdisciplinary AI Research Laboratory} + +The Department of Computer Science officially opened the Stanford Interdisciplinary AI Laboratory (SIAL) on March 15, with a ribbon-cutting ceremony attended by university president Dr.\\ Jonathan Blake and industry partners from Google, Microsoft, and NVIDIA. + +The \$45 million facility spans 25,000 square feet on the second floor of the newly renovated Gates Building and houses state-of-the-art computing infrastructure, including a cluster of 128 NVIDIA H100 GPUs and dedicated wet lab space for AI-driven biological research. + +``This laboratory represents our commitment to developing AI technologies that address humanity's greatest challenges,'' said department chair Prof.\\ Robert Chen at the opening ceremony. ``By bringing together researchers from computer science, medicine, climate science, and the social sciences, we aim to create AI systems that are not only powerful but also responsible and beneficial.'' + +\textbf{Research Focus Areas:} +\begin{itemize}[leftmargin=1.2em, nosep, itemsep=2pt] + \item \textbf{AI for Healthcare}: Drug discovery, medical imaging, and personalized medicine + \item \textbf{Climate AI}: Weather prediction, carbon capture optimization, and sustainable energy + \item \textbf{Trustworthy AI}: Fairness, interpretability, robustness, and alignment + \item \textbf{Foundations}: Large language models, multimodal learning, and reasoning +\end{itemize} + +The lab will host 12 faculty members, 40 graduate students, and 15 postdoctoral researchers. Industry partnerships include a \$10 million gift from Google DeepMind and a \$5 million equipment grant from NVIDIA. + +% ──── Faculty Spotlight ──── +\section{Faculty Spotlight: Prof.\\ Soo-Jin Kim} + +\subsection{From Seoul to Stanford: A Journey in Computational Biology} + +Prof.\\ Soo-Jin Kim joined the department in September 2024 after a distinguished postdoctoral fellowship at the Broad Institute of MIT and Harvard. Her research focuses on developing machine learning methods for understanding gene regulation and predicting the effects of genetic mutations on protein function. + +``I was drawn to computer science because of its potential to transform biology,'' Kim said in a recent interview. ``We are generating biological data at an unprecedented rate, and computational methods are essential for making sense of it all.'' + +Since arriving at Stanford, Kim has already secured a \$1.2 million NIH R01 grant for her project on ``Deep Learning Models for Predicting Variant Effects in Non-Coding Regulatory Regions.'' Her group currently includes three PhD students and two postdocs. + +\textbf{Selected recent publications:} +\begin{enumerate}[leftmargin=1.5em, nosep, itemsep=2pt] + \item Kim, S.-J. et al. ``RegulomeNet: Predicting regulatory variant effects with graph attention networks.'' \textit{Nature Methods}, 2024. + \item Kim, S.-J. and Park, H. ``Foundation models for genomics: A survey.'' \textit{Nature Reviews Genetics}, 2024. +\end{enumerate} + +% ──── Student News ──── +\section{Student Hackathon: Record Participation} + +The 8th Annual CS Department Hackathon, held over the weekend of February 22--23, drew a record 280 participants organized into 64 teams. This year's theme, ``AI for Social Good,'' challenged students to build applications addressing real-world social challenges in 36 hours. + +\subsection{Winning Projects} + +\begin{description}[leftmargin=0em, style=nextline, itemsep=4pt] + \item[1st Place -- ``AccessiVision'' (Team Alpha)] + A real-time mobile app that uses computer vision to help visually impaired users navigate indoor spaces. The app identifies obstacles, reads signs, and provides audio directions. Built with PyTorch Mobile and ARCore. + + \item[2nd Place -- ``CrisisMap'' (Team Resilience)] + A platform that aggregates social media posts during natural disasters to create real-time maps of affected areas, resource needs, and safe routes. Uses NLP to classify and geolocate posts. + + \item[3rd Place -- ``FairLend'' (Team Justice)] + An auditing tool that detects and mitigates bias in lending algorithms. Provides interpretable reports showing how different demographic groups are affected by model decisions. +\end{description} + +The winning teams received prizes including internship offers from sponsoring companies and travel grants to present at the ACM Student Research Competition. + +\columnbreak + +% ──── Alumni Interview ──── +\section{Alumni Interview: Dr.\\ Marcus Chen (Ph.D.\\ '18)} + +\textit{Dr.\\ Marcus Chen graduated from our department in 2018 and is now VP of Engineering at Anthropic, where he leads the infrastructure team responsible for training large language models.} + +\textbf{Q: How did your time at Stanford prepare you for your current role?} + +A: The rigorous theoretical foundation I received at Stanford has been invaluable. My work on distributed systems with Prof.\\ Ousterhout taught me how to think about large-scale infrastructure challenges. But equally important were the soft skills---collaborating with researchers from different backgrounds, communicating complex ideas clearly, and managing uncertainty. + +\textbf{Q: What advice would you give to current students?} + +A: Three things. First, invest in fundamentals---algorithms, systems, and math. Trends come and go, but fundamentals are forever. Second, work on something you genuinely care about; your best work will come from intrinsic motivation. Third, build things. The gap between theory and practice is where the most interesting problems live. + +\textbf{Q: What is the most exciting development in AI right now?} + +A: I am most excited about AI systems that can reason and plan, not just pattern-match. The progress in chain-of-thought reasoning and tool use suggests we are approaching systems that can genuinely assist with complex intellectual work. But we need to be thoughtful about safety and alignment---these are not afterthoughts but core engineering challenges. + +% ──── Upcoming Events ──── +\section{Upcoming Events} + +\renewcommand{\arraystretch}{1.3} +\begin{tabularx}{\columnwidth}{@{}lX@{}} +\toprule +\textbf{Date} & \textbf{Event} \\ +\midrule +Apr 3 & Distinguished Lecture: Prof.\\ Yann LeCun, ``Objective-Driven AI'' \\ +Apr 10--11 & Spring Research Symposium \\ +Apr 15 & PhD Admissions Visit Day \\ +Apr 22 & Industry Panel: ``Careers in AI'' \\ +May 1 & Thesis proposal deadline (Fall graduates) \\ +May 8 & Department Awards Banquet \\ +May 15 & Senior project presentations \\ +Jun 15 & Commencement ceremony \\ +\bottomrule +\end{tabularx} + +% ──── Quick Stats ──── +\section{Department by the Numbers} + +\begin{center} +\begin{tikzpicture}[ + statbox/.style={rectangle, draw=primary, fill=lightbg, rounded corners=4pt, minimum width=3.2cm, minimum height=1.4cm, align=center} +] + \node[statbox] at (0,0) {{\Large\bfseries\color{accent} 847}\\\\{\tiny Undergrad majors}}; + \node[statbox] at (3.6,0) {{\Large\bfseries\color{accent} 312}\\\\{\tiny Graduate students}}; + \node[statbox] at (0,-1.8) {{\Large\bfseries\color{accent} 68}\\\\{\tiny Faculty members}}; + \node[statbox] at (3.6,-1.8) {{\Large\bfseries\color{accent} \$94M}\\\\{\tiny Research funding}}; +\end{tikzpicture} +\end{center} + +% ──── Announcements ──── +\section{Announcements} + +\begin{itemize}[leftmargin=1.2em, itemsep=4pt] + \item \textbf{New course}: CS 329A ``Foundation Models'' will be offered for the first time in Fall 2025, co-taught by Profs.\\ Liang and Hashimoto. + \item \textbf{Computing resources}: The department cluster has been upgraded with 64 additional A100 GPUs. Submit allocation requests via the department portal. + \item \textbf{Travel grants}: Applications for the Summer Conference Travel Fund are due May 1. Awards of up to \$2,500 are available. + \item \textbf{Wellness}: The department peer counseling program holds drop-in hours every Wednesday 3--5pm in Gates 182. +\end{itemize} + +\vspace{0.5cm} +\begin{center} +{\color{rulecolor}\rule{0.8\columnwidth}{0.5pt}}\\[6pt] +{\small\color{darktext} +\textbf{The Computing Chronicle} is published quarterly.\\ +Submit articles and announcements to \href{mailto:newsletter@cs.stanford.edu}{newsletter@cs.stanford.edu}\\ +Back issues: \href{https://cs.stanford.edu/newsletter}{cs.stanford.edu/newsletter}} +\end{center} + +\end{multicols} + +\end{document} diff --git a/apps/desktop/public/examples/paper-acm/main.tex b/apps/desktop/public/examples/paper-acm/main.tex new file mode 100644 index 0000000..d074c55 --- /dev/null +++ b/apps/desktop/public/examples/paper-acm/main.tex @@ -0,0 +1,222 @@ +\documentclass[sigconf,nonacm]{acmart} + +\usepackage{booktabs} +\usepackage{lipsum} +\usepackage{listings} +\usepackage{xcolor} + +\lstset{ + basicstyle=\ttfamily\small, + keywordstyle=\color{blue!70!black}\bfseries, + commentstyle=\color{green!50!black}\itshape, + stringstyle=\color{red!60!black}, + frame=single, + breaklines=true, + numbers=left, + numberstyle=\tiny\color{gray}, + tabsize=2 +} + +\title{ConversaDB: A Context-Aware Query Engine for Conversational Database Interactions} + +\author{Emily R. Nakamura} +\affiliation{ + \institution{University of Washington} + \department{Paul G. Allen School of Computer Science} + \city{Seattle} + \state{WA} + \country{USA} +} +\email{enakamura@cs.washington.edu} + +\author{Carlos A. Mendez} +\affiliation{ + \institution{ETH Zurich} + \department{Department of Computer Science} + \city{Zurich} + \country{Switzerland} +} +\email{mendez@inf.ethz.ch} + +\author{Priya S. Gupta} +\affiliation{ + \institution{Microsoft Research} + \city{Redmond} + \state{WA} + \country{USA} +} +\email{priyag@microsoft.com} + +\begin{document} + +\begin{abstract} +Natural language interfaces to databases (NLIDBs) have long been a goal of the database and NLP communities. While recent advances in large language models (LLMs) have dramatically improved text-to-SQL accuracy, existing systems treat each query independently, ignoring the conversational context that is natural in real-world database interactions. We present ConversaDB, a context-aware query engine that maintains a multi-turn dialogue state to resolve ambiguities, handle co-references, and support follow-up queries. Our system introduces three key innovations: (1)~a dialogue state tracker that maintains a structured representation of the conversation history, including referenced tables, columns, and filter conditions; (2)~a context-aware SQL generation module that conditions on both the current utterance and the dialogue state; and (3)~an interactive disambiguation protocol that proactively seeks clarification when query intent is uncertain. We evaluate ConversaDB on the SParC and CoSQL benchmarks, achieving 71.4\% and 53.8\% question match accuracy respectively, representing improvements of 6.2\% and 8.1\% over the previous state of the art. A user study with 48 participants confirms that ConversaDB significantly reduces the number of interaction turns required to complete complex analytical tasks. +\end{abstract} + +\keywords{natural language interfaces, text-to-SQL, conversational AI, database systems, dialogue state tracking} + +\maketitle + +\section{Introduction} + +Databases store vast amounts of structured information, yet accessing this data requires proficiency in query languages such as SQL. Natural language interfaces to databases (NLIDBs) aim to bridge this gap by allowing users to express their information needs in plain language. Recent progress in large language models has led to dramatic improvements in single-turn text-to-SQL systems, with state-of-the-art models achieving over 80\% accuracy on the Spider benchmark~\cite{yu2018spider}. + +However, real-world database interactions are inherently conversational. Users rarely formulate their complete information need in a single query. Instead, they engage in multi-turn dialogues, refining their queries, asking follow-up questions, and exploring the data incrementally. Consider the following example interaction: + +\begin{enumerate} +\item \textit{``Show me sales by region for Q4 2024.''} +\item \textit{``What about the previous quarter?''} +\item \textit{``Sort those by revenue.''} +\item \textit{``Which region had the highest growth?''} +\end{enumerate} + +Each utterance after the first requires understanding the conversational context to generate the correct SQL query. Utterance~2 requires resolving ``the previous quarter'' relative to Q4~2024. Utterance~3 refers to ``those,'' which co-references the results from utterance~2. These phenomena---temporal references, co-references, and ellipsis---are pervasive in natural dialogue but poorly handled by single-turn systems. + +\paragraph{Contributions.} We make the following contributions: + +\begin{itemize} +\item We introduce ConversaDB, a context-aware query engine that maintains structured dialogue state for multi-turn database interactions. +\item We propose a novel context-aware SQL generation architecture that combines dialogue state tracking with constrained decoding. +\item We design an interactive disambiguation protocol that reduces errors by proactively seeking clarification. +\item We achieve new state-of-the-art results on SParC and CoSQL benchmarks and validate our system through a comprehensive user study. +\end{itemize} + +\section{Related Work} + +\subsection{Text-to-SQL} + +The text-to-SQL task has seen rapid progress in recent years. Early approaches used rule-based parsing and template filling~\cite{li2014constructing}. Neural approaches, beginning with Seq2SQL~\cite{zhong2017seq2sql}, framed the problem as sequence-to-sequence translation. More recent work leverages pre-trained language models, with systems like RESDSQL~\cite{li2023resdsql} achieving over 80\% exact match accuracy on Spider. + +\subsection{Conversational Text-to-SQL} + +SParC~\cite{yu2019sparc} and CoSQL~\cite{yu2019cosql} introduced benchmarks for context-dependent text-to-SQL. EditSQL~\cite{zhang2019editing} proposed editing the previous SQL query to handle follow-up questions. IGSQL~\cite{cai2020igsql} used an interaction graph to capture cross-turn dependencies. Despite these advances, handling complex co-references and temporal expressions remains challenging. + +\section{System Architecture} + +ConversaDB consists of three main components: (1)~a Dialogue State Tracker (DST), (2)~a Context-Aware SQL Generator (CASG), and (3)~an Interactive Disambiguation Module (IDM). Figure~1 illustrates the overall architecture. + +\subsection{Dialogue State Tracker} + +The DST maintains a structured representation $\mathcal{S}_t$ at each turn $t$ that captures the essential context from the conversation history: + +\begin{equation} +\mathcal{S}_t = (\mathcal{T}_t, \mathcal{C}_t, \mathcal{F}_t, \mathcal{R}_t, Q_{t-1}) +\end{equation} + +\noindent where $\mathcal{T}_t$ is the set of referenced tables, $\mathcal{C}_t$ is the set of referenced columns, $\mathcal{F}_t$ represents active filter conditions, $\mathcal{R}_t$ captures the result schema from the previous query, and $Q_{t-1}$ is the most recent SQL query. At each turn, the DST updates the state based on the new utterance $u_t$: + +\begin{equation} +\mathcal{S}_t = \text{DST}(\mathcal{S}_{t-1}, u_t, \mathcal{DB}) +\end{equation} + +\noindent where $\mathcal{DB}$ denotes the database schema. + +\subsection{Context-Aware SQL Generator} + +The CASG generates SQL queries conditioned on both the current utterance and the dialogue state. We encode the input as a linearized sequence: + +\begin{equation} +x_t = [u_t; \text{serialize}(\mathcal{S}_t); \text{serialize}(\mathcal{DB})] +\end{equation} + +The SQL query is generated autoregressively with schema-aware constrained decoding that ensures syntactic validity. We fine-tune a pre-trained T5-large model with the following objective: + +\begin{equation} +\mathcal{L} = -\sum_{t=1}^{T} \sum_{j=1}^{|q_t|} \log P(q_t^j \mid q_t^{ 1$, then: +\begin{equation} + \|u - u_N\|_{L^2} \leq C \rho^{-N} \max_{z \in \mathcal{E}_\rho} |u(z)| +\end{equation} +\end{theorem} + +\section{Thesis Outline} + +The remainder of this thesis is organized as follows: +\begin{description} + \item[Chapter 2] reviews the mathematical foundations of spectral methods, including polynomial interpolation, quadrature rules, and the spectral element method. + \item[Chapter 3] presents our stabilized spectral element formulation for the Navier--Stokes equations. + \item[Chapter 4] develops adaptive spectral methods for singularly perturbed problems. + \item[Chapter 5] applies spectral collocation to free-boundary problems. + \item[Chapter 6] summarizes our contributions and discusses future research directions. +\end{description} + +\chapter{Mathematical Foundations} +\label{ch:foundations} + +\section{Polynomial Interpolation and Approximation} + +The foundation of spectral methods rests on polynomial interpolation at carefully chosen nodes. Let $\{x_j\}_{j=0}^N$ be a set of distinct interpolation nodes in $[-1, 1]$. The unique polynomial $p_N$ of degree at most $N$ that interpolates a function $u$ at these nodes can be written using the Lagrange basis: +\begin{equation} + p_N(x) = \sum_{j=0}^{N} u(x_j) \ell_j(x), \quad \ell_j(x) = \prod_{\substack{k=0 \\ k \neq j}}^{N} \frac{x - x_k}{x_j - x_k} +\end{equation} + +\begin{definition}[Lebesgue Constant] +The Lebesgue constant associated with the node set $\{x_j\}_{j=0}^N$ is defined as: +\begin{equation} + \Lambda_N = \max_{x \in [-1,1]} \sum_{j=0}^{N} |\ell_j(x)| +\end{equation} +\end{definition} + +The Lebesgue constant quantifies the stability of polynomial interpolation. For equispaced nodes, $\Lambda_N$ grows exponentially with $N$, leading to the well-known Runge phenomenon. In contrast, Chebyshev nodes yield $\Lambda_N = O(\log N)$, providing nearly optimal interpolation. + +\section{Gauss Quadrature} + +Spectral methods require efficient and accurate numerical integration. Gaussian quadrature rules achieve the highest possible algebraic accuracy for polynomial integrands: +\begin{equation} + \int_{-1}^{1} f(x) w(x) \, dx \approx \sum_{j=0}^{N} w_j f(x_j) +\end{equation} +where $\{x_j\}$ are the roots of the $(N+1)$-th degree orthogonal polynomial associated with the weight function $w(x)$, and $\{w_j\}$ are the corresponding quadrature weights. + +\begin{table}[htbp] +\centering +\caption{Comparison of standard Gaussian quadrature rules.} +\label{tab:quadrature} +\begin{tabular}{@{}llcc@{}} +\toprule +\textbf{Rule} & \textbf{Weight $w(x)$} & \textbf{Degree of Exactness} & \textbf{Includes Endpoints} \\ +\midrule +Gauss--Legendre & $1$ & $2N+1$ & No \\ +Gauss--Lobatto & $1$ & $2N-1$ & Yes \\ +Gauss--Chebyshev & $(1-x^2)^{-1/2}$ & $2N+1$ & No \\ +Gauss--Jacobi & $(1-x)^\alpha(1+x)^\beta$ & $2N+1$ & No \\ +\bottomrule +\end{tabular} +\end{table} + +\section{The Spectral Element Method} + +The spectral element method (SEM) combines the geometric flexibility of finite elements with the high-order accuracy of spectral methods. The computational domain $\Omega$ is decomposed into non-overlapping elements $\{\Omega_e\}_{e=1}^{E}$: +\begin{equation} + \overline{\Omega} = \bigcup_{e=1}^{E} \overline{\Omega}_e, \quad \Omega_i \cap \Omega_j = \emptyset \text{ for } i \neq j +\end{equation} + +Within each element, the solution is approximated by high-degree polynomials using a Gauss--Lobatto--Legendre (GLL) nodal basis. The GLL nodes include the element endpoints, facilitating $C^0$ continuity across elements through simple nodal matching. + +\begin{proposition}[SEM Error Estimate] +Let $u \in H^s(\Omega)$ with $s > 1$, and let $u_{N,h}$ be the SEM approximation with polynomial degree $N$ and element size $h$. Then: +\begin{equation} + \|u - u_{N,h}\|_{H^1(\Omega)} \leq C h^{\min(s,N)-1} N^{1-s} \|u\|_{H^s(\Omega)} +\end{equation} +\end{proposition} + +This estimate reveals the dual convergence mechanism of the SEM: algebraic convergence in $h$ (mesh refinement) and spectral convergence in $N$ (polynomial enrichment). In practice, $p$-refinement (increasing $N$) is preferred for smooth solutions, while $h$-refinement is used to resolve singularities and boundary layers. + +\chapter{Stabilized Spectral Elements for the Navier--Stokes Equations} +\label{ch:navier_stokes} + +\section{Weak Formulation} + +We consider the time-dependent incompressible Navier--Stokes equations~\eqref{eq:ns_momentum}--\eqref{eq:ns_continuity} on a bounded domain $\Omega \subset \mathbb{R}^d$ ($d = 2, 3$) with suitable boundary conditions. The weak formulation seeks $(\mathbf{u}, p) \in \mathbf{V} \times Q$ such that for all $(\mathbf{v}, q) \in \mathbf{V} \times Q$: +\begin{align} + \left(\frac{\partial \mathbf{u}}{\partial t}, \mathbf{v}\right) + a(\mathbf{u}, \mathbf{v}) + c(\mathbf{u}; \mathbf{u}, \mathbf{v}) + b(\mathbf{v}, p) &= (\mathbf{f}, \mathbf{v}) \\ + b(\mathbf{u}, q) &= 0 +\end{align} +where $a(\cdot,\cdot)$ is the viscous bilinear form, $c(\cdot;\cdot,\cdot)$ is the convective trilinear form, and $b(\cdot,\cdot)$ is the pressure-velocity coupling form. + +\section{Stabilization Strategy} + +A well-known difficulty in the spectral element discretization of the Navier--Stokes equations is the need to satisfy the inf-sup (LBB) condition. We propose a new pressure stabilization technique based on polynomial filtering that maintains spectral accuracy while eliminating spurious pressure modes. + +The key idea is to introduce a spectrally small perturbation to the continuity equation: +\begin{equation} + b(\mathbf{u}_N, q_N) - \epsilon_N (\Pi_{N-2} p_N, \Pi_{N-2} q_N) = 0 +\end{equation} +where $\Pi_{N-2}$ is the $L^2$ projection onto polynomials of degree $N-2$ and $\epsilon_N = O(N^{-2s})$ for solutions in $H^s$. + +\section{Numerical Results} + +We validate our stabilized formulation on two benchmark problems: lid-driven cavity flow and flow past a circular cylinder. For the cavity flow at $\text{Re} = 1000$, our method produces smooth pressure fields without oscillations while maintaining spectral convergence rates. For the cylinder flow at $\text{Re} = 100$, we accurately capture the Von K\'{a}rm\'{a}n vortex street with a coarse spectral element mesh ($E = 48$ elements, $N = 8$). + +\chapter{Conclusions and Future Work} +\label{ch:conclusions} + +\section{Summary of Contributions} + +This thesis has developed three novel spectral methods for nonlinear PDEs with applications to fluid dynamics: + +\begin{enumerate} +\item A stabilized spectral element method for the incompressible Navier--Stokes equations that eliminates spurious pressure modes while preserving exponential convergence. + +\item An adaptive spectral method for singularly perturbed convection-diffusion equations with rigorous \textit{a posteriori} error estimates. + +\item A spectral collocation method for Stefan-type free-boundary problems with a Newton-based solver for the coupled system. +\end{enumerate} + +\section{Future Directions} + +Several promising research directions emerge from this work. First, extending the stabilized spectral element formulation to compressible flows and turbulence modeling using large eddy simulation (LES) would be a natural next step. Second, the adaptive spectral method could be generalized to handle systems of PDEs and multiphysics problems. Third, the free-boundary methodology could be applied to more complex phase-change problems, including dendritic solidification and multi-component alloy systems. + +\appendix + +\chapter{Proof of the Main Convergence Theorem} +\label{app:proof} + +\begin{proof} +We provide the detailed proof of Theorem~3.1 stated in Chapter~3. Let $e_N = u - u_N$ denote the approximation error. By the triangle inequality: +\begin{equation} + \|e_N\|_{H^1} \leq \|u - \Pi_N u\|_{H^1} + \|\Pi_N u - u_N\|_{H^1} +\end{equation} +The first term is the best approximation error, bounded by standard approximation theory. For the second term, we use the coercivity of the bilinear form $a(\cdot,\cdot)$ and the stability of the stabilization operator to obtain the desired estimate. The full technical details involve careful tracking of the stabilization parameter $\epsilon_N$ and its interaction with the polynomial degree. +\end{proof} + +\bibliographystyle{plainnat} +% \bibliography{references} + +\begin{thebibliography}{10} + +\bibitem[Canuto et~al.(2006)]{canuto2006spectral} +C.~Canuto, M.~Y. Hussaini, A.~Quarteroni, and T.~A. Zang. +\newblock \textit{Spectral Methods: Fundamentals in Single Domains}. +\newblock Springer, 2006. + +\bibitem[Karniadakis and Sherwin(2005)]{karniadakis2005spectral} +G.~E. Karniadakis and S.~J. Sherwin. +\newblock \textit{Spectral/hp Element Methods for Computational Fluid Dynamics}. +\newblock Oxford University Press, 2nd edition, 2005. + +\bibitem[Deville et~al.(2002)]{deville2002high} +M.~O. Deville, P.~F. Fischer, and E.~H. Mund. +\newblock \textit{High-Order Methods for Incompressible Fluid Flow}. +\newblock Cambridge University Press, 2002. + +\bibitem[Trefethen(2000)]{trefethen2000spectral} +L.~N. Trefethen. +\newblock \textit{Spectral Methods in MATLAB}. +\newblock SIAM, 2000. + +\end{thebibliography} + +\end{document} diff --git a/apps/desktop/public/examples/thesis-standard/references.bib b/apps/desktop/public/examples/thesis-standard/references.bib new file mode 100644 index 0000000..5b1d50a --- /dev/null +++ b/apps/desktop/public/examples/thesis-standard/references.bib @@ -0,0 +1 @@ +% empty bibliography diff --git a/apps/desktop/scripts/generate-previews.ts b/apps/desktop/scripts/generate-previews.ts new file mode 100644 index 0000000..351e0fb --- /dev/null +++ b/apps/desktop/scripts/generate-previews.ts @@ -0,0 +1,135 @@ +/** + * Generate example projects for all LaTeX templates. + * + * Usage: pnpm --filter @claude-prism/desktop generate-previews + * + * Requires pdflatex to be installed (part of TeX Live / MacTeX). + * Output: public/examples/{template-id}/main.tex, main.pdf, references.bib (if applicable) + */ + +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Import template registry (pure data, no DOM dependencies) +const registryPath = path.resolve(__dirname, "../src/lib/template-registry.ts"); + +// Since this is a .ts file with exports, we rely on tsx to handle it +async function loadTemplates() { + const mod = await import(registryPath); + return mod.getAllTemplates() as Array<{ + id: string; + name: string; + mainFileName: string; + content: string; + hasBibliography: boolean; + }>; +} + +const EXAMPLES_DIR = path.resolve(__dirname, "../public/examples"); +const COMPILE_TIMEOUT = 30_000; + +async function main() { + console.log("Generating example projects...\n"); + + const templates = await loadTemplates(); + let successCount = 0; + let failCount = 0; + + for (const template of templates) { + process.stdout.write(` ${template.id}... `); + + const exampleDir = path.join(EXAMPLES_DIR, template.id); + fs.mkdirSync(exampleDir, { recursive: true }); + + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), `prism-preview-${template.id}-`), + ); + + try { + // Write .tex file (inject \null for blank templates to force a page) + let content = template.content; + if (template.id === "blank") { + content = content.replace( + "\\begin{document}", + "\\begin{document}\n\\null", + ); + } + const texPath = path.join(tmpDir, template.mainFileName); + fs.writeFileSync(texPath, content, "utf-8"); + + // Write stub .bib if template uses bibliography + if (template.hasBibliography) { + fs.writeFileSync( + path.join(tmpDir, "references.bib"), + "% empty bibliography\n", + "utf-8", + ); + } + + const cmd = [ + "pdflatex", + "-interaction=nonstopmode", + `-output-directory=${tmpDir}`, + texPath, + ].join(" "); + + // First pass + execSync(cmd, { cwd: tmpDir, timeout: COMPILE_TIMEOUT, stdio: "pipe" }); + + // Second pass (for TOC, references, etc.) + try { + execSync(cmd, { cwd: tmpDir, timeout: COMPILE_TIMEOUT, stdio: "pipe" }); + } catch { + // Second pass failure is non-fatal + } + + // Copy source files to example folder + fs.copyFileSync(texPath, path.join(exampleDir, template.mainFileName)); + if (template.hasBibliography) { + fs.copyFileSync( + path.join(tmpDir, "references.bib"), + path.join(exampleDir, "references.bib"), + ); + } + + // Copy compiled PDF to example folder + const pdfName = template.mainFileName.replace(/\.tex$/, ".pdf"); + const pdfPath = path.join(tmpDir, pdfName); + + if (fs.existsSync(pdfPath)) { + fs.copyFileSync(pdfPath, path.join(exampleDir, pdfName)); + const sizeKb = Math.round(fs.statSync(path.join(exampleDir, pdfName)).size / 1024); + console.log(`OK (${sizeKb} KB)`); + successCount++; + } else { + console.log("WARN: no PDF output"); + failCount++; + } + } catch (err) { + const msg = err instanceof Error ? err.message.slice(0, 120) : String(err); + console.log(`FAIL: ${msg}`); + failCount++; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } + + console.log(`\nDone: ${successCount} succeeded, ${failCount} failed`); + if (failCount > 0) { + console.log( + "Note: Failed templates may require document classes not installed in your TeX distribution.", + ); + console.log("The gallery will show CSS fallback thumbnails for those templates."); + } +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 0e1b080..bc060a7 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -323,6 +323,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "serde_yaml", "sha1", "tauri", "tauri-build", @@ -331,6 +332,8 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-shell", "tokio", + "uuid", + "which", ] [[package]] @@ -684,6 +687,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.6" @@ -713,6 +722,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -1829,6 +1844,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -3045,6 +3066,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.36" @@ -3324,6 +3358,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4400,6 +4447,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -4757,6 +4810,18 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5285,6 +5350,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index fcbda78..60be184 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-plugin-shell = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" tokio = { version = "1", features = ["full"] } dirs = "5" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } @@ -28,6 +29,8 @@ base64 = "0.22" dotenvy = "0.15" git2 = "0.20" chrono = "0.4" +uuid = { version = "1", features = ["v4"] } +which = "7" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 7e7d455..55457d0 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -40,7 +40,8 @@ "allow": [ { "path": "$HOME/**" }, { "path": "$DOCUMENT/**" }, - { "path": "$DESKTOP/**" } + { "path": "$DESKTOP/**" }, + { "path": "$TEMP/**" } ] }, "shell:default", diff --git a/apps/desktop/src-tauri/gen/schemas/capabilities.json b/apps/desktop/src-tauri/gen/schemas/capabilities.json index 7f50862..da37190 100644 --- a/apps/desktop/src-tauri/gen/schemas/capabilities.json +++ b/apps/desktop/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["*"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:webview:allow-create-webview-window","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","fs:default","fs:allow-read","fs:allow-write","fs:allow-exists","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-copy-file","fs:allow-read-dir","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-read-file","fs:allow-write-file","fs:allow-stat","fs:allow-watch",{"identifier":"fs:scope","allow":[{"path":"$HOME/**"},{"path":"$DOCUMENT/**"},{"path":"$DESKTOP/**"}]},"shell:default","shell:allow-open","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","process:default","process:allow-exit"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["*"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:webview:allow-create-webview-window","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","fs:default","fs:allow-read","fs:allow-write","fs:allow-exists","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-copy-file","fs:allow-read-dir","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-read-file","fs:allow-write-file","fs:allow-stat","fs:allow-watch",{"identifier":"fs:scope","allow":[{"path":"$HOME/**"},{"path":"$DOCUMENT/**"},{"path":"$DESKTOP/**"},{"path":"$TEMP/**"}]},"shell:default","shell:allow-open","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","process:default","process:allow-exit"]}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..4928da2 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index 83f0734..ee62f51 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -20,57 +20,90 @@ impl Default for ClaudeProcessState { } /// Discover the claude binary on the system. -/// Checks: which claude → NVM paths → standard paths → bare fallback. +/// Checks: which/where claude → NVM paths → standard paths → bare fallback. fn find_claude_binary() -> Result { - // 1. Try `which claude` - if let Ok(output) = std::process::Command::new("which") - .arg("claude") - .output() - { - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !path.is_empty() && PathBuf::from(&path).exists() { - return Ok(path); - } - } + // 1. Try to find claude on PATH + if let Ok(path) = which::which("claude") { + return Ok(path.to_string_lossy().to_string()); } - // 2. Check NVM directories + // 2. Check NVM directories (Unix) or npm global (Windows) if let Some(home) = dirs::home_dir() { - let nvm_dir = home.join(".nvm").join("versions").join("node"); - if nvm_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&nvm_dir) { - let mut candidates: Vec = entries - .filter_map(|e| e.ok()) - .map(|e| e.path().join("bin").join("claude")) - .filter(|p| p.exists()) - .collect(); - // Sort by version (directory name) descending to prefer latest - candidates.sort(); - candidates.reverse(); - if let Some(path) = candidates.first() { - return Ok(path.to_string_lossy().to_string()); + #[cfg(not(target_os = "windows"))] + { + let nvm_dir = home.join(".nvm").join("versions").join("node"); + if nvm_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&nvm_dir) { + let mut candidates: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path().join("bin").join("claude")) + .filter(|p| p.exists()) + .collect(); + // Sort by version (directory name) descending to prefer latest + candidates.sort(); + candidates.reverse(); + if let Some(path) = candidates.first() { + return Ok(path.to_string_lossy().to_string()); + } + } + } + } + + #[cfg(target_os = "windows")] + { + // Check common Windows Node.js locations + if let Ok(appdata) = std::env::var("APPDATA") { + let npm_global = PathBuf::from(&appdata).join("npm").join("claude.cmd"); + if npm_global.exists() { + return Ok(npm_global.to_string_lossy().to_string()); + } + } + // NVM for Windows + if let Ok(nvm_home) = std::env::var("NVM_HOME") { + // nvm symlink lives under NVM_SYMLINK (default: C:\Program Files\nodejs) + if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") { + let p = PathBuf::from(&nvm_symlink).join("claude.cmd"); + if p.exists() { + return Ok(p.to_string_lossy().to_string()); + } + } + // Also scan NVM_HOME/ + if let Ok(entries) = std::fs::read_dir(&nvm_home) { + let mut candidates: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path().join("claude.cmd")) + .filter(|p| p.exists()) + .collect(); + candidates.sort(); + candidates.reverse(); + if let Some(path) = candidates.first() { + return Ok(path.to_string_lossy().to_string()); + } } } } } - // 3. Check standard paths - let standard_paths = [ - "/usr/local/bin/claude", - "/opt/homebrew/bin/claude", - "/usr/bin/claude", - "/bin/claude", - ]; - for path in &standard_paths { - if PathBuf::from(path).exists() { - return Ok(path.to_string()); + // 3. Check standard paths (Unix only) + #[cfg(not(target_os = "windows"))] + { + let standard_paths = [ + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + "/usr/bin/claude", + "/bin/claude", + ]; + for path in &standard_paths { + if PathBuf::from(path).exists() { + return Ok(path.to_string()); + } } } // 4. Check user-specific paths if let Some(home) = dirs::home_dir() { - let user_paths = [ + #[cfg(not(target_os = "windows"))] + let user_paths = vec![ home.join(".claude").join("local").join("claude"), home.join(".local").join("bin").join("claude"), home.join(".npm-global").join("bin").join("claude"), @@ -78,6 +111,12 @@ fn find_claude_binary() -> Result { home.join(".bun").join("bin").join("claude"), home.join("bin").join("claude"), ]; + #[cfg(target_os = "windows")] + let user_paths = vec![ + home.join(".claude").join("local").join("claude.exe"), + home.join("AppData").join("Local").join("Programs").join("claude").join("claude.exe"), + ]; + for path in &user_paths { if path.exists() { return Ok(path.to_string_lossy().to_string()); @@ -90,7 +129,7 @@ fn find_claude_binary() -> Result { } /// Create a tokio Command with appropriate environment variables. -fn create_command(program: &str, args: Vec, cwd: &str) -> Command { +fn create_command(program: &str, args: Vec, cwd: &str, effort_level: Option<&str>) -> Command { let mut cmd = Command::new(program); cmd.args(&args); cmd.current_dir(cwd); @@ -99,50 +138,29 @@ fn create_command(program: &str, args: Vec, cwd: &str) -> Command { cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - // Inherit essential environment variables - for (key, value) in std::env::vars() { - if key == "PATH" - || key == "HOME" - || key == "USER" - || key == "SHELL" - || key == "LANG" - || key == "LC_ALL" - || key.starts_with("LC_") - || key == "NODE_PATH" - || key == "NVM_DIR" - || key == "NVM_BIN" - || key == "HOMEBREW_PREFIX" - || key == "HOMEBREW_CELLAR" - || key == "HTTP_PROXY" - || key == "HTTPS_PROXY" - || key == "NO_PROXY" - || key == "ALL_PROXY" - { - cmd.env(&key, &value); + // Remove all Claude Code internal env vars to prevent nested session detection + // and other interference. Tauri inherits these when launched from a Claude Code session. + cmd.env_remove("CLAUDECODE"); + cmd.env_remove("CLAUDE_AGENT_SDK_VERSION"); + for (key, _) in std::env::vars() { + if key.starts_with("CLAUDE_CODE_") || key.starts_with("CLAUDE_AGENT_") { + cmd.env_remove(&key); } } + // Set effort level (default: low for fast responses) + cmd.env("CLAUDE_CODE_EFFORT_LEVEL", effort_level.unwrap_or("low")); - // Add NVM support if the program is in an NVM directory - if program.contains("/.nvm/versions/node/") { - if let Some(node_bin_dir) = std::path::Path::new(program).parent() { - let current_path = std::env::var("PATH").unwrap_or_default(); - let node_bin_str = node_bin_dir.to_string_lossy(); - if !current_path.contains(node_bin_str.as_ref()) { - let new_path = format!("{}:{}", node_bin_str, current_path); - cmd.env("PATH", new_path); - } - } - } - - // Add Homebrew support - if program.contains("/homebrew/") || program.contains("/opt/homebrew/") { - if let Some(program_dir) = std::path::Path::new(program).parent() { - let current_path = std::env::var("PATH").unwrap_or_default(); - let homebrew_bin_str = program_dir.to_string_lossy(); - if !current_path.contains(homebrew_bin_str.as_ref()) { - let new_path = format!("{}:{}", homebrew_bin_str, current_path); - cmd.env("PATH", new_path); - } + // Add the program's parent directory to PATH if not already present + if let Some(program_dir) = std::path::Path::new(program).parent() { + let current_path = std::env::var("PATH").unwrap_or_default(); + let program_dir_str = program_dir.to_string_lossy(); + if !current_path.contains(program_dir_str.as_ref()) { + #[cfg(target_os = "windows")] + let sep = ";"; + #[cfg(not(target_os = "windows"))] + let sep = ":"; + let new_path = format!("{}{}{}", program_dir_str, sep, current_path); + cmd.env("PATH", new_path); } } @@ -182,14 +200,24 @@ async fn spawn_claude_process( let session_id_holder: Arc>> = Arc::new(std::sync::Mutex::new(None)); + let start_time = std::time::Instant::now(); + // Spawn stdout streaming task — emit only to the originating window let win_stdout = window.clone(); let session_id_stdout = session_id_holder.clone(); let stdout_task = tokio::spawn(async move { let mut lines = stdout_reader.lines(); + let mut line_count: u64 = 0; while let Ok(Some(line)) = lines.next_line().await { + line_count += 1; + let elapsed = start_time.elapsed().as_secs_f64(); + // Parse for system:init to extract session_id if let Ok(msg) = serde_json::from_str::(&line) { + let msg_type = msg["type"].as_str().unwrap_or("?"); + let msg_sub = msg["subtype"].as_str().unwrap_or(""); + eprintln!("[claude-stdout] +{:.1}s #{} type={} sub={} len={}", elapsed, line_count, msg_type, msg_sub, line.len()); + if msg["type"] == "system" && msg["subtype"] == "init" { if let Some(sid) = msg["session_id"].as_str() { let mut guard = session_id_stdout.lock().unwrap(); @@ -198,24 +226,18 @@ async fn spawn_claude_process( } } - // Emit session-scoped event to this window only - if let Some(ref sid) = *session_id_stdout.lock().unwrap() { - let _ = win_stdout.emit(&format!("claude-output:{}", sid), &line); - } - // Always emit generic event to this window + // Emit output event to this window let _ = win_stdout.emit("claude-output", &line); } + eprintln!("[claude-stdout] stream ended after {} lines ({:.1}s)", line_count, start_time.elapsed().as_secs_f64()); }); // Spawn stderr streaming task — emit only to the originating window let win_stderr = window.clone(); - let session_id_stderr = session_id_holder.clone(); let stderr_task = tokio::spawn(async move { let mut lines = stderr_reader.lines(); while let Ok(Some(line)) = lines.next_line().await { - if let Some(ref sid) = *session_id_stderr.lock().unwrap() { - let _ = win_stderr.emit(&format!("claude-error:{}", sid), &line); - } + eprintln!("[claude-stderr] +{:.1}s {}", start_time.elapsed().as_secs_f64(), &line[..line.len().min(200)]); let _ = win_stderr.emit("claude-error", &line); } }); @@ -224,7 +246,6 @@ async fn spawn_claude_process( let process_arc_wait = process_arc.clone(); let win_wait = window; let window_label_wait = window_label; - let session_id_wait = session_id_holder.clone(); tokio::spawn(async move { // Wait for stdout/stderr to finish let _ = stdout_task.await; @@ -234,27 +255,298 @@ async fn spawn_claude_process( let mut processes = process_arc_wait.lock().await; let success = if let Some(mut child) = processes.remove(&window_label_wait) { match child.wait().await { - Ok(status) => status.success(), - Err(_) => false, + Ok(status) => { + eprintln!("[claude-process] exited with status={} ({:.1}s)", status, start_time.elapsed().as_secs_f64()); + status.success() + } + Err(e) => { + eprintln!("[claude-process] wait error: {} ({:.1}s)", e, start_time.elapsed().as_secs_f64()); + false + } } } else { + eprintln!("[claude-process] no child found in map ({:.1}s)", start_time.elapsed().as_secs_f64()); false }; drop(processes); - // Small delay to ensure all events are flushed - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - // Emit completion event to this window only - if let Some(ref sid) = *session_id_wait.lock().unwrap() { - let _ = win_wait.emit(&format!("claude-complete:{}", sid), success); - } + // Emit completion event to this window let _ = win_wait.emit("claude-complete", success); }); Ok(()) } +// ─── Setup / Status Commands ─── + +#[derive(serde::Serialize)] +pub struct ClaudeStatus { + pub installed: bool, + pub authenticated: bool, + pub binary_path: Option, + pub version: Option, + pub account_email: Option, +} + +#[tauri::command] +pub async fn check_claude_status() -> Result { + // Try to find binary + let binary_path = match find_claude_binary() { + Ok(path) => path, + Err(_) => { + return Ok(ClaudeStatus { + installed: false, + authenticated: false, + binary_path: None, + version: None, + account_email: None, + }); + } + }; + + // Verify binary actually works by running --version + let version_output = std::process::Command::new(&binary_path) + .arg("--version") + .output(); + + let version = match version_output { + Ok(output) if output.status.success() => { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + _ => { + // Binary found but doesn't work (bare "claude" fallback or broken install) + return Ok(ClaudeStatus { + installed: false, + authenticated: false, + binary_path: None, + version: None, + account_email: None, + }); + } + }; + + // Check auth status + let auth_output = std::process::Command::new(&binary_path) + .args(["auth", "status"]) + .output(); + + let (authenticated, account_email) = match auth_output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + // Parse for email — claude auth status outputs account info + let email = stdout + .lines() + .find(|line| line.contains('@')) + .map(|line| { + // Extract email-like substring + line.split_whitespace() + .find(|word| word.contains('@')) + .unwrap_or(line.trim()) + .to_string() + }); + (true, email) + } + _ => (false, None), + }; + + Ok(ClaudeStatus { + installed: true, + authenticated, + binary_path: Some(binary_path), + version, + account_email, + }) +} + +#[tauri::command] +pub async fn install_claude_cli(window: WebviewWindow) -> Result<(), String> { + #[cfg(not(target_os = "windows"))] + let mut cmd = { + let mut c = tokio::process::Command::new("bash"); + c.args(["-c", "curl -fsSL https://claude.ai/install.sh | bash"]); + c + }; + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = tokio::process::Command::new("powershell"); + c.args(["-NoProfile", "-Command", "irm https://claude.ai/install.ps1 | iex"]); + c + }; + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + // Inherit essential environment variables + for (key, value) in std::env::vars() { + if key == "PATH" + || key == "HOME" + || key == "USER" + || key == "SHELL" + || key == "LANG" + || key.starts_with("LC_") + || key == "HOMEBREW_PREFIX" + || key == "HOMEBREW_CELLAR" + || key == "HTTP_PROXY" + || key == "HTTPS_PROXY" + || key == "NO_PROXY" + || key == "ALL_PROXY" + { + cmd.env(&key, &value); + } + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to run installer: {}", e))?; + + let stdout = child.stdout.take().ok_or("Failed to capture stdout")?; + let stderr = child.stderr.take().ok_or("Failed to capture stderr")?; + + let stdout_reader = BufReader::new(stdout); + let stderr_reader = BufReader::new(stderr); + + // Stream stdout + let win_stdout = window.clone(); + let stdout_task = tokio::spawn(async move { + let mut lines = stdout_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = win_stdout.emit("install-output", &line); + } + }); + + // Stream stderr + let win_stderr = window.clone(); + let stderr_task = tokio::spawn(async move { + let mut lines = stderr_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = win_stderr.emit("install-error", &line); + } + }); + + // Wait for completion and emit result + let win_complete = window; + tokio::spawn(async move { + let _ = stdout_task.await; + let _ = stderr_task.await; + + let success = match child.wait().await { + Ok(status) => status.success(), + Err(_) => false, + }; + + let _ = win_complete.emit("install-complete", success); + }); + + Ok(()) +} + +#[tauri::command] +pub async fn login_claude(window: WebviewWindow) -> Result<(), String> { + let binary_path = find_claude_binary() + .map_err(|e| format!("Claude CLI not found: {}", e))?; + + // Verify it actually exists + let version_check = std::process::Command::new(&binary_path) + .arg("--version") + .output(); + + if version_check.is_err() || !version_check.unwrap().status.success() { + return Err("Claude CLI is not properly installed".to_string()); + } + + let mut cmd = tokio::process::Command::new(&binary_path); + cmd.args(["auth", "login"]); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + // Inherit essential environment variables + for (key, value) in std::env::vars() { + if key == "PATH" + || key == "HOME" + || key == "USER" + || key == "SHELL" + || key == "LANG" + || key.starts_with("LC_") + || key == "HOMEBREW_PREFIX" + || key == "HOMEBREW_CELLAR" + || key == "HTTP_PROXY" + || key == "HTTPS_PROXY" + || key == "NO_PROXY" + || key == "ALL_PROXY" + { + cmd.env(&key, &value); + } + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to run auth login: {}", e))?; + + let stdout = child.stdout.take().ok_or("Failed to capture stdout")?; + let stderr = child.stderr.take().ok_or("Failed to capture stderr")?; + + let stdout_reader = BufReader::new(stdout); + let stderr_reader = BufReader::new(stderr); + + // Stream stdout + let win_stdout = window.clone(); + let stdout_task = tokio::spawn(async move { + let mut lines = stdout_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = win_stdout.emit("login-output", &line); + } + }); + + // Stream stderr + let win_stderr = window.clone(); + let stderr_task = tokio::spawn(async move { + let mut lines = stderr_reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = win_stderr.emit("login-error", &line); + } + }); + + // Wait for completion and emit result + let win_complete = window; + tokio::spawn(async move { + let _ = stdout_task.await; + let _ = stderr_task.await; + + let success = match child.wait().await { + Ok(status) => status.success(), + Err(_) => false, + }; + + let _ = win_complete.emit("login-complete", success); + }); + + Ok(()) +} + +/// Common CLI flags shared across all Claude invocations. +fn common_claude_args() -> Vec { + vec![ + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--dangerously-skip-permissions".to_string(), + "--append-system-prompt".to_string(), + concat!( + "You are an AI assistant integrated into a LaTeX document editor (Prism). ", + "Follow these rules strictly:\n", + "1. PLANNING FIRST: Before making changes, use TodoWrite to create a step-by-step plan. ", + "Break large tasks into small, incremental steps (one section or one logical unit per step).\n", + "2. INCREMENTAL EDITS: Use the Edit tool to make small, targeted changes — one step at a time. ", + "NEVER write or rewrite an entire file at once. Always prefer editing existing content over replacing it wholesale.\n", + "3. STEP BY STEP: After each edit, mark the todo item as completed, then proceed to the next step. ", + "This lets the user review changes incrementally.\n", + "4. PRESERVE EXISTING CONTENT: Always read the file first. Keep the existing preamble, packages, ", + "and structure intact. Only add or modify what is needed for the current step.\n", + "5. LaTeX BEST PRACTICES: Use proper sectioning (\\chapter, \\section, \\subsection), ", + "citations (\\cite), cross-references (\\label, \\ref), and BibTeX for bibliographies." + ).to_string(), + ] +} + // ─── Tauri Commands ─── #[tauri::command] @@ -262,22 +554,22 @@ pub async fn execute_claude_code( window: WebviewWindow, project_path: String, prompt: String, - model: String, + model: Option, + effort_level: Option, ) -> Result<(), String> { let claude_path = find_claude_binary()?; - let args = vec![ + let mut args = vec![ "-p".to_string(), prompt, - "--model".to_string(), - model, - "--output-format".to_string(), - "stream-json".to_string(), - "--verbose".to_string(), - "--dangerously-skip-permissions".to_string(), ]; + if let Some(m) = model { + args.push("--model".to_string()); + args.push(m); + } + args.extend(common_claude_args()); - let cmd = create_command(&claude_path, args, &project_path); + let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); spawn_claude_process(window, cmd).await } @@ -286,23 +578,23 @@ pub async fn continue_claude_code( window: WebviewWindow, project_path: String, prompt: String, - model: String, + model: Option, + effort_level: Option, ) -> Result<(), String> { let claude_path = find_claude_binary()?; - let args = vec![ + let mut args = vec![ "-c".to_string(), "-p".to_string(), prompt, - "--model".to_string(), - model, - "--output-format".to_string(), - "stream-json".to_string(), - "--verbose".to_string(), - "--dangerously-skip-permissions".to_string(), ]; + if let Some(m) = model { + args.push("--model".to_string()); + args.push(m); + } + args.extend(common_claude_args()); - let cmd = create_command(&claude_path, args, &project_path); + let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); spawn_claude_process(window, cmd).await } @@ -312,24 +604,24 @@ pub async fn resume_claude_code( project_path: String, session_id: String, prompt: String, - model: String, + model: Option, + effort_level: Option, ) -> Result<(), String> { let claude_path = find_claude_binary()?; - let args = vec![ + let mut args = vec![ "--resume".to_string(), session_id, "-p".to_string(), prompt, - "--model".to_string(), - model, - "--output-format".to_string(), - "stream-json".to_string(), - "--verbose".to_string(), - "--dangerously-skip-permissions".to_string(), ]; + if let Some(m) = model { + args.push("--model".to_string()); + args.push(m); + } + args.extend(common_claude_args()); - let cmd = create_command(&claude_path, args, &project_path); + let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); spawn_claude_process(window, cmd).await } @@ -410,8 +702,9 @@ fn clean_user_message_title(text: &str) -> Option { return None; } - let title = if clean.len() > 80 { - format!("{}...", &clean[..77]) + let title = if clean.chars().count() > 80 { + let truncated: String = clean.chars().take(77).collect(); + format!("{}...", truncated) } else { clean.to_string() }; @@ -578,8 +871,11 @@ pub async fn run_shell_command( command: String, cwd: String, ) -> Result { - let args = vec!["-c".to_string(), command]; - let mut cmd = create_command("sh", args, &cwd); + #[cfg(not(target_os = "windows"))] + let (shell, args) = ("sh", vec!["-c".to_string(), command]); + #[cfg(target_os = "windows")] + let (shell, args) = ("cmd", vec!["/C".to_string(), command]); + let mut cmd = create_command(shell, args, &cwd, None); let child = cmd .spawn() @@ -596,3 +892,60 @@ pub async fn run_shell_command( stderr: String::from_utf8_lossy(&output.stderr).to_string(), }) } + +// ─── Claude Settings (fast mode, etc.) ─── + +fn get_claude_settings_path() -> Result { + let home = dirs::home_dir().ok_or("Could not find home directory")?; + Ok(home.join(".claude").join("settings.json")) +} + +#[tauri::command] +pub async fn get_claude_fast_mode() -> Result { + let path = get_claude_settings_path()?; + if !path.exists() { + return Ok(false); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read settings: {}", e))?; + let settings: serde_json::Value = serde_json::from_str(&content) + .unwrap_or(serde_json::json!({})); + Ok(settings["fastMode"].as_bool().unwrap_or(false)) +} + +#[tauri::command] +pub async fn set_claude_fast_mode(enabled: bool) -> Result<(), String> { + let path = get_claude_settings_path()?; + + // Ensure directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create settings dir: {}", e))?; + } + + // Read existing settings or create new + let mut settings: serde_json::Value = if path.exists() { + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read settings: {}", e))?; + serde_json::from_str(&content).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + // Update fastMode + if let Some(obj) = settings.as_object_mut() { + if enabled { + obj.insert("fastMode".to_string(), serde_json::json!(true)); + } else { + obj.remove("fastMode"); + } + } + + // Write back + let content = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + std::fs::write(&path, content) + .map_err(|e| format!("Failed to write settings: {}", e))?; + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/history.rs b/apps/desktop/src-tauri/src/history.rs index 3a7cfe9..a1e9b34 100644 --- a/apps/desktop/src-tauri/src/history.rs +++ b/apps/desktop/src-tauri/src/history.rs @@ -62,8 +62,7 @@ fn ensure_excludes(project_root: &str, repo: &Repository) { let excludes_path = Path::new(project_root) .join(".claudeprism") .join("history-exclude"); - if !excludes_path.exists() { - let content = r#"# LaTeX build artifacts + let content = r#"# LaTeX build artifacts *.aux *.log *.out @@ -93,8 +92,17 @@ Thumbs.db # ClaudePrism internal .claudeprism/ +.prism/ "#; + if !excludes_path.exists() { let _ = fs::write(&excludes_path, content); + } else { + // Migrate: add .prism/ if missing from existing excludes file + if let Ok(existing) = fs::read_to_string(&excludes_path) { + if !existing.contains(".prism/") { + let _ = fs::write(&excludes_path, content); + } + } } // Configure the repo to use this excludes file if let Ok(mut config) = repo.config() { diff --git a/apps/desktop/src-tauri/src/latex.rs b/apps/desktop/src-tauri/src/latex.rs new file mode 100644 index 0000000..098cddf --- /dev/null +++ b/apps/desktop/src-tauri/src/latex.rs @@ -0,0 +1,767 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::{Mutex, Semaphore}; + +const MAX_CONCURRENT: usize = 3; +const COMPILE_TIMEOUT_SECS: u64 = 30; + +struct BuildInfo { + work_dir: PathBuf, + main_file_name: String, // stem without extension, e.g. "document" + aux_hash: Option, // hash of .aux file from last build (for single-pass optimization) + preamble_hash: Option, // hash of preamble text (for format regeneration) + fmt_ok: bool, // whether .fmt was successfully generated +} + +#[derive(Clone)] +pub struct LatexCompilerState { + last_builds: Arc>>, + /// Per-project locks to prevent concurrent compilations on the same build directory. + project_locks: Arc>>>>, + semaphore: Arc, +} + +impl Default for LatexCompilerState { + fn default() -> Self { + Self { + last_builds: Arc::new(Mutex::new(HashMap::new())), + project_locks: Arc::new(Mutex::new(HashMap::new())), + semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT)), + } + } +} + +// CompileResult is no longer needed — compile_latex returns raw PDF bytes via Response. + +#[derive(serde::Serialize)] +pub struct SynctexResult { + pub file: String, + pub line: u32, + pub column: u32, +} + +// --- Helpers --- + +struct CommandOutput { + exit_code: i32, + timed_out: bool, + stdout: String, + stderr: String, +} + +/// Build an augmented PATH that includes common TeX installation directories. +/// GUI apps don't always inherit the user's shell PATH, so pdflatex etc. won't be found. +fn tex_path() -> String { + let mut path = std::env::var("PATH").unwrap_or_default(); + + #[cfg(target_os = "macos")] + let (extras, sep) = ( + vec![ + "/Library/TeX/texbin".to_string(), + "/usr/local/texlive/2024/bin/universal-darwin".to_string(), + "/usr/local/texlive/2023/bin/universal-darwin".to_string(), + "/usr/texbin".to_string(), + "/opt/homebrew/bin".to_string(), + "/usr/local/bin".to_string(), + ], + ":", + ); + + #[cfg(target_os = "windows")] + let (extras, sep) = ({ + let mut v = vec![ + "C:\\texlive\\2024\\bin\\windows".to_string(), + "C:\\texlive\\2023\\bin\\windows".to_string(), + "C:\\Program Files\\MiKTeX\\miktex\\bin\\x64".to_string(), + ]; + // Also check user-local MiKTeX + if let Ok(localappdata) = std::env::var("LOCALAPPDATA") { + v.push(format!("{}\\Programs\\MiKTeX\\miktex\\bin\\x64", localappdata)); + } + v + }, ";"); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let (extras, sep): (Vec, &str) = ( + vec![ + "/usr/local/texlive/2024/bin/x86_64-linux".to_string(), + "/usr/local/texlive/2023/bin/x86_64-linux".to_string(), + "/usr/local/bin".to_string(), + ], + ":", + ); + + for extra in &extras { + if Path::new(extra).exists() && !path.contains(extra.as_str()) { + path = format!("{}{}{}", extra, sep, path); + } + } + path +} + +async fn run_with_timeout( + program: &str, + args: &[&str], + cwd: &Path, + timeout_secs: u64, +) -> Result { + let mut cmd = tokio::process::Command::new(program); + cmd.args(args) + .current_dir(cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .env("PATH", tex_path()); + + #[cfg(not(target_os = "windows"))] + if let Ok(home) = std::env::var("HOME") { + cmd.env("HOME", home); + } + #[cfg(target_os = "windows")] + if let Ok(home) = std::env::var("USERPROFILE") { + cmd.env("USERPROFILE", home); + } + + let child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn {}: {}", program, e))?; + + match tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + child.wait_with_output(), + ) + .await + { + Ok(Ok(output)) => Ok(CommandOutput { + exit_code: output.status.code().unwrap_or(-1), + timed_out: false, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }), + Ok(Err(e)) => Err(format!("Process error: {}", e)), + Err(_) => Ok(CommandOutput { + exit_code: -1, + timed_out: true, + stdout: String::new(), + stderr: "Compilation timed out".to_string(), + }), + } +} + +fn extract_error_lines(log: &str) -> String { + if log.is_empty() { + return String::new(); + } + + if log.lines().any(|l| l.contains("No pages of output")) { + return "No pages of output. Add visible content to the document body.".to_string(); + } + + let error_lines: Vec<&str> = log + .lines() + .filter(|l| l.starts_with('!') || l.contains("Error:") || l.contains("error:")) + .take(10) + .collect(); + + if error_lines.is_empty() { + let start = log.len().saturating_sub(500); + log[start..].to_string() + } else { + error_lines.join("\n") + } +} + +fn has_bib_files(dir: &Path) -> bool { + fn check(dir: &Path) -> bool { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return false, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if check(&path) { + return true; + } + } else if path.extension().map(|e| e == "bib").unwrap_or(false) { + return true; + } + } + false + } + check(dir) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + if !dst.exists() { + std::fs::create_dir_all(dst)?; + } + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + // Skip hidden directories (.git, .claudeprism, etc.) + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { + continue; + } + copy_dir_recursive(&src_path, &dst_path)?; + } else { + std::fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +/// Sync only source files (.tex, .bib, .sty, .cls, .bst, images) from project to build dir. +/// Skips build artifacts (.aux, .log, .toc, .pdf, .synctex.gz, etc.) to preserve them. +fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> { + if !dst.exists() { + std::fs::create_dir_all(dst)?; + } + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { + continue; + } + sync_source_files(&src_path, &dst_path)?; + } else { + let ext = src_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let is_artifact = matches!(ext, "aux" | "log" | "toc" | "lof" | "lot" | "out" + | "nav" | "snm" | "vrb" | "bbl" | "blg" | "fls" | "fdb_latexmk" + | "synctex" | "pdf" | "idx" | "ind" | "ilg" | "glo" | "gls" | "glg"); + let is_synctex = src_path.to_string_lossy().ends_with(".synctex.gz"); + if !is_artifact && !is_synctex { + std::fs::copy(&src_path, &dst_path)?; + } + } + } + Ok(()) +} + +/// Persistent build directory inside the project. +/// Stored in `/.prism/build/` — hidden from file tree (dot-prefix is filtered). +fn persistent_build_dir(project_dir: &str) -> PathBuf { + PathBuf::from(project_dir).join(".prism").join("build") +} + +/// Recover preamble hash from on-disk `_prism_preamble.tex` (has `\dump\n` suffix). +fn recover_preamble_hash(work_dir: &Path) -> Option { + let preamble_tex = work_dir.join("_prism_preamble.tex"); + if !preamble_tex.exists() { + return None; + } + let content = std::fs::read_to_string(&preamble_tex).ok()?; + let preamble = content + .strip_suffix("\\dump\n") + .or_else(|| content.strip_suffix("\\dump")) + .unwrap_or(&content); + Some(hash_string(preamble)) +} + +/// Hash a string (for preamble change detection). +fn hash_string(s: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() +} + +/// Extract preamble info from a .tex file. +/// Returns (preamble_text, byte_pos_of_begin_document, newline_count_in_preamble). +fn extract_preamble_info(content: &str) -> Option<(String, usize, usize)> { + let marker = "\\begin{document}"; + if let Some(pos) = content.find(marker) { + let preamble = content[..pos].to_string(); + let newlines = content[..pos].chars().filter(|&c| c == '\n').count(); + Some((preamble, pos, newlines)) + } else { + None + } +} + +/// Try to generate a pre-compiled format (.fmt) from the preamble. +/// Returns true if the format was successfully generated. +async fn try_generate_format( + preamble: &str, + work_dir: &Path, + compiler_cmd: &str, +) -> bool { + // Write preamble + \dump to a temp .tex file + let preamble_path = work_dir.join("_prism_preamble.tex"); + let dump_content = format!("{}\\dump\n", preamble); + if std::fs::write(&preamble_path, &dump_content).is_err() { + eprintln!("[latex] failed to write format preamble file"); + return false; + } + + let ini_load = format!("&{}", compiler_cmd); + match run_with_timeout( + compiler_cmd, + &["-ini", "-jobname=_prism_fmt", &ini_load, "_prism_preamble.tex"], + work_dir, + COMPILE_TIMEOUT_SECS, + ) + .await + { + Ok(result) => { + let fmt_path = work_dir.join("_prism_fmt.fmt"); + if fmt_path.exists() { + let size = std::fs::metadata(&fmt_path).map(|m| m.len()).unwrap_or(0); + eprintln!( + "[latex] format generated ({} bytes, exit={})", + size, result.exit_code + ); + true + } else { + eprintln!( + "[latex] format generation failed (exit={}, no .fmt)", + result.exit_code + ); + if !result.stderr.is_empty() { + let n = result.stderr.len().min(300); + eprintln!("[latex] fmt stderr: {}", &result.stderr[..n]); + } + false + } + } + Err(e) => { + eprintln!("[latex] format generation error: {}", e); + false + } + } +} + +/// Write a body file with padding comment lines to preserve line numbers. +/// The body file starts with `newline_count` padding lines, then \begin{document}... +/// For pdflatex, injects `\pdfcompresslevel=0` to speed up PDF generation. +/// Always injects `\AtEndDocument{\null}` to guarantee at least one page of output +/// (prevents "No pages of output" for empty document bodies). +fn write_body_file( + content: &str, + begin_doc_pos: usize, + newline_count: usize, + work_dir: &Path, + compiler: &str, +) -> std::io::Result<()> { + let body = &content[begin_doc_pos..]; + // Inject before \begin{document}: + // - pdfcompresslevel=0 for faster PDF generation (pdflatex only) + // - \AtEndDocument{\null} to ensure at least one page of output + let inject = if compiler == "pdflatex" { + "\\pdfcompresslevel=0 \\pdfobjcompresslevel=0 \\AtEndDocument{\\null}\n" + } else { + "\\AtEndDocument{\\null}\n" + }; + let mut result = String::with_capacity(body.len() + newline_count * 2 + 80); + if newline_count > 1 { + for _ in 0..newline_count - 1 { + result.push_str("%\n"); + } + result.push_str(inject); + } else if newline_count == 1 { + result.push_str(inject); + } else { + // newline_count == 0: no padding room, inject on its own line + result.push_str(inject); + } + result.push_str(body); + std::fs::write(work_dir.join("_prism_body.tex"), &result) +} + +/// Hash the contents of a file (for aux-file change detection). +fn hash_file(path: &Path) -> Option { + use std::hash::{Hash, Hasher}; + let data = std::fs::read(path).ok()?; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + data.hash(&mut hasher); + Some(hasher.finish()) +} + +// --- Tauri Commands --- + +#[tauri::command] +pub async fn compile_latex( + state: tauri::State<'_, LatexCompilerState>, + project_dir: String, + main_file: String, + compiler: Option, +) -> Result { + // Acquire semaphore permit (non-blocking) + let _permit = state + .semaphore + .clone() + .try_acquire_owned() + .map_err(|_| "Server busy, too many concurrent compilations".to_string())?; + + // Acquire per-project lock to prevent concurrent compilations on the same build dir. + // This serializes compilations for the same project while allowing different projects + // to compile in parallel. + let project_lock = { + let mut locks = state.project_locks.lock().await; + locks + .entry(project_dir.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _project_guard = project_lock.lock().await; + + let t0 = std::time::Instant::now(); + + let compiler_cmd = match compiler.as_deref() { + Some("xelatex") => "xelatex", + Some("lualatex") => "lualatex", + _ => "pdflatex", + }; + + let main_file_name = Path::new(&main_file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("document") + .to_string(); + + // Reuse existing build directory: check in-memory cache, then persistent disk cache + let (work_dir, prev_aux_hash, prev_preamble_hash, prev_fmt_ok, is_reuse) = { + let builds = state.last_builds.lock().await; + if let Some(prev) = builds.get(&project_dir) { + if prev.work_dir.exists() { + // In-memory cache hit + ( + prev.work_dir.clone(), + prev.aux_hash, + prev.preamble_hash, + prev.fmt_ok, + true, + ) + } else { + (PathBuf::new(), None, None, false, false) + } + } else { + // Check persistent disk cache (survives app restarts) + let persistent = persistent_build_dir(&project_dir); + if persistent.exists() { + let aux_hash = + hash_file(&persistent.join(format!("{}.aux", main_file_name))); + let preamble_hash = recover_preamble_hash(&persistent); + let fmt_ok = persistent.join("_prism_fmt.fmt").exists(); + eprintln!( + "[latex] recovered persistent cache (fmt={}, aux={}, preamble={})", + fmt_ok, + aux_hash.is_some(), + preamble_hash.is_some() + ); + (persistent, aux_hash, preamble_hash, fmt_ok, true) + } else { + (PathBuf::new(), None, None, false, false) + } + } + }; + + let work_dir = if is_reuse { + // Sync only source files, preserving build artifacts (.aux, .toc, .fmt, etc.) + sync_source_files(Path::new(&project_dir), &work_dir) + .map_err(|e| format!("Failed to sync project: {}", e))?; + eprintln!( + "[latex] +{:.0}ms sync source files (reuse)", + t0.elapsed().as_millis() + ); + work_dir + } else { + // First build ever: create persistent build directory + let work_dir = persistent_build_dir(&project_dir); + std::fs::create_dir_all(&work_dir) + .map_err(|e| format!("Failed to create build dir: {}", e))?; + copy_dir_recursive(Path::new(&project_dir), &work_dir) + .map_err(|e| format!("Failed to copy project: {}", e))?; + eprintln!( + "[latex] +{:.0}ms full copy (first build)", + t0.elapsed().as_millis() + ); + work_dir + }; + + // Pre-compiled format optimization: cache preamble loading in .fmt file + let main_tex_path = work_dir.join(&main_file); + let tex_content = std::fs::read_to_string(&main_tex_path).unwrap_or_default(); + + let (use_fmt, new_preamble_hash) = + if let Some((preamble, begin_doc_pos, newline_count)) = + extract_preamble_info(&tex_content) + { + let p_hash = hash_string(&preamble); + let fmt_path = work_dir.join("_prism_fmt.fmt"); + + let need_regen = !(is_reuse + && prev_preamble_hash == Some(p_hash) + && fmt_path.exists() + && prev_fmt_ok); + + if need_regen { + eprintln!("[latex] preamble changed or no format — generating .fmt"); + let t_fmt = std::time::Instant::now(); + let ok = try_generate_format(&preamble, &work_dir, compiler_cmd).await; + eprintln!( + "[latex] +{:.0}ms format generation (ok={})", + t_fmt.elapsed().as_millis(), + ok + ); + if ok { + write_body_file(&tex_content, begin_doc_pos, newline_count, &work_dir, compiler_cmd) + .map_err(|e| format!("Failed to write body file: {}", e))?; + (true, Some(p_hash)) + } else { + (false, Some(p_hash)) + } + } else { + // Format cached and valid — just update body file (content may have changed) + write_body_file(&tex_content, begin_doc_pos, newline_count, &work_dir, compiler_cmd) + .map_err(|e| format!("Failed to write body file: {}", e))?; + eprintln!("[latex] reusing cached format"); + (true, Some(p_hash)) + } + } else { + (false, None) + }; + + eprintln!( + "[latex] +{:.0}ms format setup (use_fmt={})", + t0.elapsed().as_millis(), + use_fmt + ); + + // Detect .bib files + let has_bib = has_bib_files(&work_dir); + + // Build compilation arguments + let fmt_arg = "-fmt=_prism_fmt".to_string(); + let jobname_arg = format!("-jobname={}", main_file_name); + let latex_args: Vec<&str> = if use_fmt { + vec![ + "-interaction=nonstopmode", + "-synctex=1", + &fmt_arg, + &jobname_arg, + "_prism_body.tex", + ] + } else { + vec!["-interaction=nonstopmode", "-synctex=1", &main_file] + }; + + let mut last_result; + let mut pass_count = 0; + + // Pass 1 — always needed + last_result = + run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS).await?; + pass_count += 1; + eprintln!("[latex] +{:.0}ms pass {} done (exit={})", t0.elapsed().as_millis(), pass_count, last_result.exit_code); + if last_result.timed_out { + return Err("Compilation timed out".to_string()); + } + + // Decide whether extra passes are needed + let aux_path = work_dir.join(format!("{}.aux", main_file_name)); + let aux_hash_after_pass1 = hash_file(&aux_path); + let aux_stable = prev_aux_hash.is_some() && aux_hash_after_pass1 == prev_aux_hash; + + // Check .log for rerun warnings — avoids unnecessary pass 2 even on first build + let log_path = work_dir.join(format!("{}.log", main_file_name)); + let log_after_pass1 = std::fs::read_to_string(&log_path).unwrap_or_default(); + let needs_rerun = log_after_pass1.contains("Rerun to get") + || log_after_pass1.contains("Rerun LaTeX") + || log_after_pass1.contains("Label(s) may have changed"); + + eprintln!( + "[latex] aux_stable={} needs_rerun={} prev_hash={:?} new_hash={:?}", + aux_stable, needs_rerun, prev_aux_hash, aux_hash_after_pass1 + ); + + if !aux_stable && needs_rerun { + if has_bib { + // BibTeX + 2 more passes + if aux_path.exists() { + last_result = run_with_timeout( + "bibtex", + &[main_file_name.as_str()], + &work_dir, + COMPILE_TIMEOUT_SECS, + ) + .await?; + eprintln!("[latex] +{:.0}ms bibtex done (exit={})", t0.elapsed().as_millis(), last_result.exit_code); + if last_result.timed_out { + return Err("BibTeX timed out".to_string()); + } + } + for _ in 0..2 { + last_result = + run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS) + .await?; + pass_count += 1; + eprintln!("[latex] +{:.0}ms pass {} done (exit={})", t0.elapsed().as_millis(), pass_count, last_result.exit_code); + if last_result.timed_out { + return Err("Compilation timed out".to_string()); + } + } + } else { + // One more pass to resolve cross-references + last_result = + run_with_timeout(compiler_cmd, &latex_args, &work_dir, COMPILE_TIMEOUT_SECS) + .await?; + pass_count += 1; + eprintln!("[latex] +{:.0}ms pass {} done (exit={})", t0.elapsed().as_millis(), pass_count, last_result.exit_code); + if last_result.timed_out { + return Err("Compilation timed out".to_string()); + } + } + } else { + eprintln!("[latex] skipping extra passes (aux_stable={}, needs_rerun={})", aux_stable, needs_rerun); + } + + // Final aux hash for next compilation + let final_aux_hash = hash_file(&aux_path); + eprintln!("[latex] +{:.0}ms total ({} passes, reuse={}, bib={})", t0.elapsed().as_millis(), pass_count, is_reuse, has_bib); + + // Re-read log file (may have been updated by extra passes) + let log_content = std::fs::read_to_string(&log_path).unwrap_or_default(); + + // Check for PDF; if "No pages of output", retry with \null injection. + // This handles the non-format path (empty documents compiled directly). + // The format path already injects \AtEndDocument{\null} via write_body_file. + let pdf_path = work_dir.join(format!("{}.pdf", main_file_name)); + if !pdf_path.exists() && log_content.contains("No pages of output") { + eprintln!("[latex] no pages of output — retrying with \\null injection"); + let null_input = format!("\\AtEndDocument{{\\null}}\\input{{{}}}", main_file); + let jobname_arg = format!("-jobname={}", main_file_name); + let retry_args: Vec<&str> = + vec!["-interaction=nonstopmode", "-synctex=1", &jobname_arg, &null_input]; + match run_with_timeout(compiler_cmd, &retry_args, &work_dir, COMPILE_TIMEOUT_SECS).await { + Ok(r) => eprintln!("[latex] empty-body retry: exit={} pdf_exists={}", r.exit_code, pdf_path.exists()), + Err(e) => eprintln!("[latex] empty-body retry error: {}", e), + } + } + + // Store build info (even on failure, for debugging / synctex) + let store_build = |builds: &mut HashMap| { + builds.insert( + project_dir.clone(), + BuildInfo { + work_dir: work_dir.clone(), + main_file_name: main_file_name.clone(), + aux_hash: final_aux_hash, + preamble_hash: new_preamble_hash, + fmt_ok: use_fmt, + }, + ); + }; + + if pdf_path.exists() { + let pdf_bytes = std::fs::read(&pdf_path) + .map_err(|e| format!("Failed to read PDF: {}", e))?; + let mut builds = state.last_builds.lock().await; + store_build(&mut builds); + Ok(tauri::ipc::Response::new(pdf_bytes)) + } else { + let mut builds = state.last_builds.lock().await; + store_build(&mut builds); + + let details = extract_error_lines(&log_content); + let fallback = if last_result.stderr.is_empty() { + let s = &last_result.stdout; + let start = s.len().saturating_sub(500); + s[start..].to_string() + } else { + last_result.stderr + }; + let msg = if details.is_empty() { fallback } else { details }; + Err(format!("Compilation failed\n\n{}", msg)) + } +} + +#[tauri::command] +pub async fn synctex_edit( + state: tauri::State<'_, LatexCompilerState>, + project_dir: String, + page: u32, + x: f64, + y: f64, +) -> Result { + let builds = state.last_builds.lock().await; + let build = builds + .get(&project_dir) + .ok_or("No build found for this project")?; + + // Verify synctex data exists + let synctex_gz = build + .work_dir + .join(format!("{}.synctex.gz", build.main_file_name)); + let synctex_plain = build + .work_dir + .join(format!("{}.synctex", build.main_file_name)); + if !synctex_gz.exists() && !synctex_plain.exists() { + return Err("No synctex data found. Recompile with synctex enabled.".to_string()); + } + + let pdf_file = format!("{}.pdf", build.main_file_name); + let coord_arg = format!("{}:{}:{}:{}", page, x, y, pdf_file); + let work_dir = build.work_dir.clone(); + let main_file_name = build.main_file_name.clone(); + drop(builds); // Release lock before spawning process + + let result = run_with_timeout("synctex", &["edit", "-o", &coord_arg], &work_dir, 10).await?; + + if result.exit_code != 0 { + return Err(format!("synctex failed: {}", result.stderr)); + } + + // Parse synctex output + let mut file = String::new(); + let mut line = 0u32; + let mut column = 0u32; + + for l in result.stdout.lines() { + let trimmed = l.trim(); + if let Some(rest) = trimmed.strip_prefix("Input:") { + file = rest.to_string(); + } else if let Some(rest) = trimmed.strip_prefix("Line:") { + line = rest.parse().unwrap_or(0); + } else if let Some(rest) = trimmed.strip_prefix("Column:") { + column = rest.parse::().unwrap_or(0).max(0) as u32; + } + } + + if file.is_empty() || line == 0 { + return Err("Could not resolve source location".to_string()); + } + + // Normalize: strip work_dir prefix and "./" or ".\\" prefix + let work_dir_str = work_dir.to_string_lossy().to_string(); + if let Some(rest) = file.strip_prefix(&format!("{}/", work_dir_str)) { + file = rest.to_string(); + } else if let Some(rest) = file.strip_prefix(&format!("{}\\", work_dir_str)) { + file = rest.to_string(); + } + if let Some(rest) = file.strip_prefix("./") { + file = rest.to_string(); + } else if let Some(rest) = file.strip_prefix(".\\") { + file = rest.to_string(); + } + + // Map format body file back to original main file + if file == "_prism_body.tex" { + file = format!("{}.tex", main_file_name); + } + + Ok(SynctexResult { file, line, column }) +} + +/// Clear in-memory build state on app exit. +/// Persistent build directories are intentionally kept for fast restart. +pub async fn cleanup_all_builds(state: &LatexCompilerState) { + let mut builds = state.last_builds.lock().await; + builds.clear(); +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index dc7507a..5aaa818 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,11 +1,93 @@ mod claude; mod history; +mod latex; +mod slash_commands; mod zotero; -use std::process::Command; -use std::sync::Mutex; +use std::path::Path; use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +// --- External editor detection & opening --- + +#[derive(serde::Serialize, Clone)] +struct EditorInfo { + id: String, + name: String, +} + +struct EditorDef { + id: &'static str, + name: &'static str, + cli: &'static str, +} + +const KNOWN_EDITORS: &[EditorDef] = &[ + EditorDef { id: "cursor", name: "Cursor", cli: "cursor" }, + EditorDef { id: "vscode", name: "VS Code", cli: "code" }, + EditorDef { id: "zed", name: "Zed", cli: "zed" }, + EditorDef { id: "sublime", name: "Sublime Text", cli: "subl" }, +]; + +#[cfg(target_os = "macos")] +const MACOS_APP_PATHS: &[(&str, &str)] = &[ + ("cursor", "/Applications/Cursor.app"), + ("vscode", "/Applications/Visual Studio Code.app"), + ("zed", "/Applications/Zed.app"), + ("sublime", "/Applications/Sublime Text.app"), +]; + +#[tauri::command] +fn detect_editors() -> Vec { + KNOWN_EDITORS + .iter() + .filter(|e| is_editor_installed(e)) + .map(|e| EditorInfo { id: e.id.to_string(), name: e.name.to_string() }) + .collect() +} + +fn is_editor_installed(editor: &EditorDef) -> bool { + #[cfg(target_os = "macos")] + { + if let Some((_, app_path)) = MACOS_APP_PATHS.iter().find(|(id, _)| *id == editor.id) { + return Path::new(app_path).exists(); + } + } + // Fallback / Windows / Linux: check if CLI is on PATH + which::which(editor.cli).is_ok() +} + +#[tauri::command] +fn open_in_editor( + editor_id: String, + project_path: String, + file_path: Option, + line: Option, +) -> Result<(), String> { + let editor = KNOWN_EDITORS + .iter() + .find(|e| e.id == editor_id) + .ok_or_else(|| format!("Unknown editor: {}", editor_id))?; + + let mut cmd = std::process::Command::new(editor.cli); + + // Open the project folder + cmd.arg(&project_path); + + // If a specific file is given, open it (with optional line number via -g) + if let Some(ref fp) = file_path { + let full_path = Path::new(&project_path).join(fp); + if let Some(ln) = line { + cmd.arg("-g"); + cmd.arg(format!("{}:{}", full_path.display(), ln)); + } else { + cmd.arg(full_path); + } + } + + cmd.spawn().map_err(|e| format!("Failed to open {}: {}", editor.name, e))?; + Ok(()) +} + #[cfg(target_os = "macos")] fn set_macos_app_icon() { use objc2::{AnyThread, MainThreadMarker}; @@ -26,35 +108,6 @@ fn set_macos_app_icon() { } } -struct SidecarState { - child: Option, -} - -fn start_sidecar(sidecar_path: &str, port: u16) -> Option { - let child = Command::new("node") - .arg(sidecar_path) - .env("PORT", port.to_string()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn(); - - match child { - Ok(child) => { - println!("Sidecar started with PID: {}", child.id()); - Some(child) - } - Err(e) => { - eprintln!("Failed to start sidecar: {}", e); - None - } - } -} - -#[tauri::command] -fn get_sidecar_url() -> String { - "http://localhost:3001".to_string() -} - #[tauri::command] fn create_new_window(app: tauri::AppHandle) -> Result<(), String> { let label = format!("window-{}", std::time::SystemTime::now() @@ -91,53 +144,31 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_process::init()) - .manage(Mutex::new(SidecarState { child: None })) .manage(claude::ClaudeProcessState::default()) + .manage(latex::LatexCompilerState::default()) .manage(zotero::ZoteroOAuthState::default()) - .setup(|app| { - // Set macOS Dock icon (dev mode doesn't use bundle, so set it at runtime) + .setup(|_app| { #[cfg(target_os = "macos")] set_macos_app_icon(); - // In dev mode, the sidecar is started separately (via pnpm dev:desktop) - // In production, start the sidecar from the bundled resources - let sidecar_path = if let Ok(path) = std::env::var("SIDECAR_PATH") { - Some(path) - } else if cfg!(not(debug_assertions)) { - let resource_dir = app - .path() - .resource_dir() - .unwrap_or_else(|_| std::path::PathBuf::from(".")); - Some( - resource_dir - .join("sidecar") - .join("index.js") - .to_string_lossy() - .to_string(), - ) - } else { - // Dev mode: sidecar is managed by the dev script - println!("Dev mode: expecting sidecar on port 3001 (start with pnpm dev:desktop)"); - None - }; - - if let Some(path) = sidecar_path { - let child = start_sidecar(&path, 3001); - let state = app.state::>(); - let mut state = state.lock().unwrap(); - state.child = child; - } - Ok(()) }) .invoke_handler(tauri::generate_handler![ - get_sidecar_url, create_new_window, + detect_editors, + open_in_editor, + latex::compile_latex, + latex::synctex_edit, + claude::check_claude_status, + claude::install_claude_cli, + claude::login_claude, claude::execute_claude_code, claude::continue_claude_code, claude::resume_claude_code, claude::cancel_claude_execution, claude::run_shell_command, + claude::get_claude_fast_mode, + claude::set_claude_fast_mode, claude::list_claude_sessions, claude::load_session_history, zotero::zotero_start_oauth, @@ -151,6 +182,10 @@ pub fn run() { history::history_restore, history::history_add_label, history::history_remove_label, + slash_commands::slash_commands_list, + slash_commands::slash_command_get, + slash_commands::slash_command_save, + slash_commands::slash_command_delete, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); @@ -172,14 +207,12 @@ pub fn run() { } } tauri::RunEvent::ExitRequested { .. } => { - // Kill sidecar on exit - let state = app_handle.state::>(); - if let Ok(mut guard) = state.lock() { - if let Some(ref mut child) = guard.child { - let _ = child.kill(); - println!("Sidecar process killed"); - } - }; + // Clean up LaTeX build temp directories + let latex_state = app_handle.state::(); + let state_clone = latex_state.inner().clone(); + tauri::async_runtime::spawn(async move { + latex::cleanup_all_builds(&state_clone).await; + }); } _ => {} } diff --git a/apps/desktop/src-tauri/src/slash_commands.rs b/apps/desktop/src-tauri/src/slash_commands.rs new file mode 100644 index 0000000..7ab8482 --- /dev/null +++ b/apps/desktop/src-tauri/src/slash_commands.rs @@ -0,0 +1,356 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlashCommand { + pub id: String, + pub name: String, + pub full_command: String, + pub scope: String, + pub namespace: Option, + pub file_path: String, + pub content: String, + pub description: Option, + pub allowed_tools: Vec, + pub has_bash_commands: bool, + pub has_file_references: bool, + pub accepts_arguments: bool, +} + +#[derive(Debug, Deserialize)] +struct CommandFrontmatter { + #[serde(rename = "allowed-tools")] + allowed_tools: Option>, + description: Option, +} + +fn parse_markdown_with_frontmatter(content: &str) -> (Option, String) { + let lines: Vec<&str> = content.lines().collect(); + + if lines.is_empty() || lines[0] != "---" { + return (None, content.to_string()); + } + + let mut frontmatter_end = None; + for (i, line) in lines.iter().enumerate().skip(1) { + if *line == "---" { + frontmatter_end = Some(i); + break; + } + } + + if let Some(end) = frontmatter_end { + let frontmatter_content = lines[1..end].join("\n"); + let body_content = lines[(end + 1)..].join("\n"); + + match serde_yaml::from_str::(&frontmatter_content) { + Ok(frontmatter) => (Some(frontmatter), body_content), + Err(_) => (None, content.to_string()), + } + } else { + (None, content.to_string()) + } +} + +fn extract_command_info(file_path: &Path, base_path: &Path) -> Option<(String, Option)> { + let relative_path = file_path.strip_prefix(base_path).ok()?; + let path_without_ext = relative_path + .with_extension("") + .to_string_lossy() + .to_string(); + + let components: Vec<&str> = path_without_ext.split('/').collect(); + + if components.is_empty() { + return None; + } + + if components.len() == 1 { + Some((components[0].to_string(), None)) + } else { + let command_name = components.last().unwrap().to_string(); + let namespace = components[..components.len() - 1].join(":"); + Some((command_name, Some(namespace))) + } +} + +fn load_command_from_file( + file_path: &Path, + base_path: &Path, + scope: &str, +) -> Option { + let content = fs::read_to_string(file_path).ok()?; + let (frontmatter, body) = parse_markdown_with_frontmatter(&content); + let (name, namespace) = extract_command_info(file_path, base_path)?; + + let full_command = match &namespace { + Some(ns) => format!("/{ns}:{name}"), + None => format!("/{name}"), + }; + + let id = format!( + "{}-{}", + scope, + file_path.to_string_lossy().replace('/', "-") + ); + + let has_bash_commands = body.contains("!`"); + let has_file_references = body.contains('@'); + let accepts_arguments = body.contains("$ARGUMENTS"); + + let (description, allowed_tools) = if let Some(fm) = frontmatter { + (fm.description, fm.allowed_tools.unwrap_or_default()) + } else { + (None, Vec::new()) + }; + + Some(SlashCommand { + id, + name, + full_command, + scope: scope.to_string(), + namespace, + file_path: file_path.to_string_lossy().to_string(), + content: body, + description, + allowed_tools, + has_bash_commands, + has_file_references, + accepts_arguments, + }) +} + +fn find_markdown_files(dir: &Path, files: &mut Vec) { + if !dir.exists() { + return; + } + + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with('.') { + continue; + } + } + + if path.is_dir() { + find_markdown_files(&path, files); + } else if path.is_file() { + if let Some(ext) = path.extension() { + if ext == "md" { + files.push(path); + } + } + } + } +} + +fn create_default_commands() -> Vec { + vec![ + SlashCommand { + id: "default-add-dir".to_string(), + name: "add-dir".to_string(), + full_command: "/add-dir".to_string(), + scope: "default".to_string(), + namespace: None, + file_path: String::new(), + content: "Add additional working directories".to_string(), + description: Some("Add additional working directories".to_string()), + allowed_tools: vec![], + has_bash_commands: false, + has_file_references: false, + accepts_arguments: false, + }, + SlashCommand { + id: "default-init".to_string(), + name: "init".to_string(), + full_command: "/init".to_string(), + scope: "default".to_string(), + namespace: None, + file_path: String::new(), + content: "Initialize project with CLAUDE.md guide".to_string(), + description: Some("Initialize project with CLAUDE.md guide".to_string()), + allowed_tools: vec![], + has_bash_commands: false, + has_file_references: false, + accepts_arguments: false, + }, + SlashCommand { + id: "default-review".to_string(), + name: "review".to_string(), + full_command: "/review".to_string(), + scope: "default".to_string(), + namespace: None, + file_path: String::new(), + content: "Request code review".to_string(), + description: Some("Request code review".to_string()), + allowed_tools: vec![], + has_bash_commands: false, + has_file_references: false, + accepts_arguments: false, + }, + ] +} + +#[tauri::command] +pub async fn slash_commands_list( + project_path: Option, +) -> Result, String> { + let mut commands = Vec::new(); + + commands.extend(create_default_commands()); + + // Load project commands + if let Some(proj_path) = project_path { + let project_commands_dir = PathBuf::from(&proj_path).join(".claude").join("commands"); + if project_commands_dir.exists() { + let mut md_files = Vec::new(); + find_markdown_files(&project_commands_dir, &mut md_files); + for file_path in md_files { + if let Some(cmd) = + load_command_from_file(&file_path, &project_commands_dir, "project") + { + commands.push(cmd); + } + } + } + } + + // Load user commands + if let Some(home_dir) = dirs::home_dir() { + let user_commands_dir = home_dir.join(".claude").join("commands"); + if user_commands_dir.exists() { + let mut md_files = Vec::new(); + find_markdown_files(&user_commands_dir, &mut md_files); + for file_path in md_files { + if let Some(cmd) = + load_command_from_file(&file_path, &user_commands_dir, "user") + { + commands.push(cmd); + } + } + } + } + + Ok(commands) +} + +#[tauri::command] +pub async fn slash_command_get(command_id: String) -> Result { + let commands = slash_commands_list(None).await?; + commands + .into_iter() + .find(|cmd| cmd.id == command_id) + .ok_or_else(|| format!("Command not found: {}", command_id)) +} + +#[tauri::command] +pub async fn slash_command_save( + scope: String, + name: String, + namespace: Option, + content: String, + description: Option, + allowed_tools: Vec, + project_path: Option, +) -> Result { + if name.is_empty() { + return Err("Command name cannot be empty".to_string()); + } + + if !["project", "user"].contains(&scope.as_str()) { + return Err("Invalid scope. Must be 'project' or 'user'".to_string()); + } + + let base_dir = if scope == "project" { + if let Some(proj_path) = project_path { + PathBuf::from(proj_path).join(".claude").join("commands") + } else { + return Err("Project path required for project scope".to_string()); + } + } else { + dirs::home_dir() + .ok_or_else(|| "Could not find home directory".to_string())? + .join(".claude") + .join("commands") + }; + + let mut file_path = base_dir.clone(); + if let Some(ns) = &namespace { + for component in ns.split(':') { + file_path = file_path.join(component); + } + } + + fs::create_dir_all(&file_path).map_err(|e| format!("Failed to create directories: {}", e))?; + + file_path = file_path.join(format!("{}.md", name)); + + let mut full_content = String::new(); + + if description.is_some() || !allowed_tools.is_empty() { + full_content.push_str("---\n"); + if let Some(desc) = &description { + full_content.push_str(&format!("description: {}\n", desc)); + } + if !allowed_tools.is_empty() { + full_content.push_str("allowed-tools:\n"); + for tool in &allowed_tools { + full_content.push_str(&format!(" - {}\n", tool)); + } + } + full_content.push_str("---\n\n"); + } + + full_content.push_str(&content); + + fs::write(&file_path, &full_content) + .map_err(|e| format!("Failed to write command file: {}", e))?; + + load_command_from_file(&file_path, &base_dir, &scope) + .ok_or_else(|| "Failed to load saved command".to_string()) +} + +#[tauri::command] +pub async fn slash_command_delete( + command_id: String, + project_path: Option, +) -> Result { + let commands = slash_commands_list(project_path).await?; + + let command = commands + .into_iter() + .find(|cmd| cmd.id == command_id) + .ok_or_else(|| format!("Command not found: {}", command_id))?; + + fs::remove_file(&command.file_path) + .map_err(|e| format!("Failed to delete command file: {}", e))?; + + // Clean up empty parent directories + if let Some(parent) = Path::new(&command.file_path).parent() { + let _ = remove_empty_dirs(parent); + } + + Ok(format!("Deleted command: {}", command.full_command)) +} + +fn remove_empty_dirs(dir: &Path) { + if !dir.exists() { + return; + } + if let Ok(mut entries) = fs::read_dir(dir) { + if entries.next().is_none() { + let _ = fs::remove_dir(dir); + if let Some(parent) = dir.parent() { + remove_empty_dirs(parent); + } + } + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 35e2afe..f1ed4ba 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -25,7 +25,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:3001 https://api.zotero.org https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' asset: http://asset.localhost data: blob:; worker-src 'self' blob:" + "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.zotero.org https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' asset: http://asset.localhost data: blob:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'self' blob:" } }, "bundle": { @@ -34,8 +34,14 @@ "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", + "icons/icon.ico", "icons/icon.png" - ] + ], + "windows": { + "nsis": { + "installMode": "perMachine" + } + } }, "plugins": {} } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 39a1dbd..e7e53b6 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -16,7 +16,7 @@ function WorkspaceWithClaude() { // Update window title useEffect(() => { if (projectRoot) { - const name = projectRoot.split("/").pop() || "ClaudePrism"; + const name = projectRoot.split(/[/\\]/).pop() || "ClaudePrism"; getCurrentWindow().setTitle(`${name} - ClaudePrism`); } }, [projectRoot]); diff --git a/apps/desktop/src/components/claude-chat/chat-composer.tsx b/apps/desktop/src/components/claude-chat/chat-composer.tsx index 1707d1f..809af49 100644 --- a/apps/desktop/src/components/claude-chat/chat-composer.tsx +++ b/apps/desktop/src/components/claude-chat/chat-composer.tsx @@ -1,15 +1,22 @@ -import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ArrowUpIcon, SquareIcon, XIcon, FileTextIcon, FileCodeIcon, FileIcon, ImageIcon, FileSpreadsheetIcon, PaperclipIcon } from "lucide-react"; +import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ArrowUpIcon, SquareIcon, XIcon, FileTextIcon, FileCodeIcon, FileIcon, ImageIcon, FileSpreadsheetIcon, PaperclipIcon, ZapIcon, CheckIcon, ChevronDownIcon, SparklesIcon, RabbitIcon, LayersIcon } from "lucide-react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs"; +import { join } from "@tauri-apps/api/path"; +import { invoke } from "@tauri-apps/api/core"; import { useClaudeChatStore, offsetToLineCol } from "@/stores/claude-chat-store"; import { useDocumentStore, type ProjectFile } from "@/stores/document-store"; +import { getUniqueTargetName } from "@/lib/tauri/fs"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; +import { SlashCommandPicker, type SlashCommand } from "./slash-command-picker"; interface PinnedContext { label: string; // @file:line:col-line:col filePath: string; selectedText: string; + imageDataUrl?: string; // thumbnail for captured images } function getFileIcon(file: ProjectFile) { @@ -24,9 +31,29 @@ export const ChatComposer: FC = () => { const sendPrompt = useClaudeChatStore((s) => s.sendPrompt); const cancelExecution = useClaudeChatStore((s) => s.cancelExecution); const isStreaming = useClaudeChatStore((s) => s.isStreaming); + const selectedModel = useClaudeChatStore((s) => s.selectedModel); + const setSelectedModel = useClaudeChatStore((s) => s.setSelectedModel); + const effortLevel = useClaudeChatStore((s) => s.effortLevel); + const setEffortLevel = useClaudeChatStore((s) => s.setEffortLevel); const [input, setInput] = useState(""); const textareaRef = useRef(null); + // Model picker state + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const modelPickerRef = useRef(null); + const modelButtonRef = useRef(null); + const [pickerPos, setPickerPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); + + // Recalculate popup position when it opens + useLayoutEffect(() => { + if (!modelPickerOpen || !modelButtonRef.current) return; + const rect = modelButtonRef.current.getBoundingClientRect(); + setPickerPos({ + left: rect.left, + bottom: window.innerHeight - rect.top + 4, + }); + }, [modelPickerOpen]); + // Pinned contexts — supports multiple files/selections const [pinnedContexts, setPinnedContexts] = useState([]); @@ -39,13 +66,37 @@ export const ChatComposer: FC = () => { const [mentionFiles, setMentionFiles] = useState([]); const mentionRef = useRef(null); + // / slash command state + const [slashQuery, setSlashQuery] = useState(null); + const [slashIndex, setSlashIndex] = useState(0); + const [slashCommands, setSlashCommands] = useState([]); + const slashFilteredCountRef = useRef(0); + // Watch selection changes to auto-pin context const selectionRange = useDocumentStore((s) => s.selectionRange); const activeFileId = useDocumentStore((s) => s.activeFileId); const files = useDocumentStore((s) => s.files); const importFiles = useDocumentStore((s) => s.importFiles); + const refreshFiles = useDocumentStore((s) => s.refreshFiles); const projectRoot = useDocumentStore((s) => s.projectRoot); + // Consume pending attachments from external sources (e.g. PDF capture) + const pendingAttachments = useClaudeChatStore((s) => s.pendingAttachments); + const consumePendingAttachments = useClaudeChatStore((s) => s.consumePendingAttachments); + + useEffect(() => { + if (pendingAttachments.length === 0) return; + const attachments = consumePendingAttachments(); + if (attachments.length === 0) return; + setPinnedContexts((prev) => { + const existingLabels = new Set(prev.map((c) => c.label)); + const unique = attachments.filter((a) => !existingLabels.has(a.label)); + return [...prev, ...unique]; + }); + // Focus textarea so user can type immediately + setTimeout(() => textareaRef.current?.focus(), 0); + }, [pendingAttachments, consumePendingAttachments]); + const currentContextLabel = useMemo(() => { if (!selectionRange) return null; const file = files.find((f) => f.id === activeFileId); @@ -88,6 +139,39 @@ export const ChatComposer: FC = () => { setMentionIndex(0); }, [mentionQuery, files]); + // Load slash commands when picker is activated + useEffect(() => { + if (slashQuery === null) { + setSlashCommands([]); + return; + } + invoke("slash_commands_list", { + projectPath: projectRoot ?? undefined, + }) + .then(setSlashCommands) + .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; @@ -118,6 +202,29 @@ export const ChatComposer: FC = () => { setTimeout(() => textarea.focus(), 0); }, [input]); + const selectSlashCommand = useCallback((command: SlashCommand) => { + // Insert command text into input (opcode-style: insert as template, don't send immediately) + const newInput = command.accepts_arguments + ? `${command.full_command} ` + : command.content; + + setInput(newInput); + setSlashQuery(null); + setSlashIndex(0); + + // Refocus and move cursor to end + setTimeout(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.focus(); + textarea.selectionStart = textarea.selectionEnd = newInput.length; + // Auto-resize + textarea.style.height = "auto"; + textarea.style.height = Math.min(textarea.scrollHeight, 160) + "px"; + } + }, 0); + }, []); + // Handle file drops — guard against duplicate calls from stale HMR listeners const isProcessingDropRef = useRef(false); const handleFileDropRef = useRef<(paths: string[]) => Promise>(async () => {}); @@ -212,22 +319,108 @@ export const ChatComposer: FC = () => { }; }, []); + // Handle clipboard paste — detect files (screenshots, images) and save to attachments/ + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const clipboardFiles = e.clipboardData?.files; + if (!clipboardFiles || clipboardFiles.length === 0 || !projectRoot) return; + + // Check if there are actual file items (not just text) + const fileItems = Array.from(clipboardFiles); + if (fileItems.length === 0) return; + + e.preventDefault(); + + const newContexts: PinnedContext[] = []; + + for (const file of fileItems) { + // Generate a filename — use the original name or a timestamp-based name for screenshots + let fileName = file.name; + if (!fileName || fileName === "image.png") { + const ext = file.type.split("/")[1] || "png"; + fileName = `paste-${Date.now()}.${ext}`; + } + + const targetName = `attachments/${fileName}`; + + try { + // Ensure attachments/ directory exists + const attachmentsDir = await join(projectRoot, "attachments"); + if (!(await exists(attachmentsDir))) { + await mkdir(attachmentsDir, { recursive: true }); + } + + // Deduplicate filename + const uniqueName = await getUniqueTargetName(projectRoot, targetName); + const fullPath = await join(projectRoot, uniqueName); + + // Read file data and write to disk + const buffer = await file.arrayBuffer(); + await writeFile(fullPath, new Uint8Array(buffer)); + + // Determine if it's a text file + const isText = file.type.startsWith("text/"); + const content = isText ? await file.text() : `[Attached file: ${uniqueName} (${file.type})]`; + + newContexts.push({ + label: `@${uniqueName}`, + filePath: uniqueName, + selectedText: content, + }); + } catch (err) { + console.error("[chat-paste] failed to save file:", fileName, err); + } + } + + if (newContexts.length > 0) { + // Refresh file list so the store knows about new files + await refreshFiles(); + + setPinnedContexts((prev) => { + const existingLabels = new Set(prev.map((c) => c.label)); + const unique = newContexts.filter((c) => !existingLabels.has(c.label)); + return [...prev, ...unique]; + }); + } + }, + [projectRoot, refreshFiles], + ); + const handleSend = useCallback(() => { const trimmed = input.trim(); if (!trimmed || isStreaming) return; + + // Resolve slash commands: if input starts with /command, find the command and substitute $ARGUMENTS + let finalPrompt = trimmed; + const slashMatch = trimmed.match(/^\/(\S+)\s*([\s\S]*)/); + if (slashMatch && slashCommands.length > 0) { + const cmdName = slashMatch[1]; + const args = slashMatch[2].trim(); + const matched = slashCommands.find( + (cmd) => cmd.full_command === `/${cmdName}` || cmd.name === cmdName, + ); + if (matched) { + finalPrompt = matched.content; + if (matched.accepts_arguments && args) { + finalPrompt = finalPrompt.replace(/\$ARGUMENTS/g, args); + } + } + } + setInput(""); setMentionQuery(null); + setSlashQuery(null); // Send with pinned context override if (pinnedContexts.length > 0) { const combinedLabel = pinnedContexts.map((c) => c.label).join(", "); const combinedText = pinnedContexts.map((c) => c.selectedText).join("\n\n---\n\n"); - sendPrompt(trimmed, { + sendPrompt(finalPrompt, { label: combinedLabel, filePath: pinnedContexts[0].filePath, selectedText: combinedText, }); } else { - sendPrompt(trimmed); + sendPrompt(finalPrompt); } // Reset textarea height if (textareaRef.current) { @@ -235,10 +428,36 @@ export const ChatComposer: FC = () => { } // Clear pinned contexts after send setPinnedContexts([]); - }, [input, isStreaming, sendPrompt, pinnedContexts]); + }, [input, isStreaming, sendPrompt, pinnedContexts, slashCommands]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + // / slash command navigation + if (slashQuery !== null && slashFilteredCountRef.current > 0) { + if (e.key === "ArrowDown") { + 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; + } + } + // @ mention navigation if (mentionQuery !== null && mentionFiles.length > 0) { if (e.key === "ArrowDown") { @@ -273,7 +492,7 @@ export const ChatComposer: FC = () => { setPinnedContexts((prev) => prev.slice(0, -1)); } }, - [handleSend, pinnedContexts, input, mentionQuery, mentionFiles, mentionIndex, selectMention], + [handleSend, pinnedContexts, input, mentionQuery, mentionFiles, mentionIndex, selectMention, slashQuery], ); const handleInput = useCallback( @@ -281,15 +500,32 @@ export const ChatComposer: FC = () => { const value = e.target.value; setInput(value); - // Detect @ mention trigger - const cursorPos = e.target.selectionStart; - const textBefore = value.slice(0, cursorPos); - // Match @ at start of input or after a space - const atMatch = textBefore.match(/(?:^|[\s])@([^\s]*)$/); - if (atMatch) { - setMentionQuery(atMatch[1]); - } else { + // Detect / slash command trigger — only at the very start of input + const slashMatch = value.match(/^\/(\S*)$/); + 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) + if (!value.startsWith("/")) { + const cursorPos = e.target.selectionStart; + const textBefore = value.slice(0, cursorPos); + // Match @ at start of input or after a space + const atMatch = textBefore.match(/(?:^|[\s])@([^\s]*)$/); + if (atMatch) { + setMentionQuery(atMatch[1]); + } else { + setMentionQuery(null); + } } // Auto-resize @@ -308,10 +544,106 @@ export const ChatComposer: FC = () => { } }, [mentionIndex]); + // Close model picker on click outside + useEffect(() => { + if (!modelPickerOpen) return; + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + if ( + modelPickerRef.current && !modelPickerRef.current.contains(target) && + modelButtonRef.current && !modelButtonRef.current.contains(target) + ) { + setModelPickerOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [modelPickerOpen]); + return (
+ {/* / slash command picker */} + {slashQuery !== null && ( + { + setSlashQuery(null); + setSlashIndex(0); + }} + /> + )} + + {/* Model picker popup — portal to body to escape all stacking contexts */} + {modelPickerOpen && createPortal( +
+ {/* Models */} +
+
Model
+ {([ + { id: "sonnet" as const, name: "Sonnet", desc: "Fast, efficient for most tasks", icon: }, + { id: "opus" as const, name: "Opus", desc: "Most capable, complex reasoning", icon: }, + { id: "haiku" as const, name: "Haiku", desc: "Fastest, simple tasks", icon: }, + { id: "opusplan" as const, name: "OpusPlan", desc: "Opus for planning, Sonnet for execution", icon: }, + ]).map((m) => ( + + ))} +
+ +
+ + {/* Effort level */} +
+
+ Effort + + {effortLevel === "low" ? "Low" : effortLevel === "medium" ? "Medium" : "High"} + +
+
+ {(["low", "medium", "high"] as const).map((level) => ( + + ))} +
+
+ +
, + document.body, + )} + {/* @ mention dropdown */} - {mentionQuery !== null && mentionFiles.length > 0 && ( + {slashQuery === null && mentionQuery !== null && mentionFiles.length > 0 && (
{ > {/* Pinned context chips */} {pinnedContexts.length > 0 && ( -
- {pinnedContexts.map((ctx, i) => ( - - {ctx.label} - - - ))} + {ctx.label} + +
+ ) : ( + + {ctx.label} + + + ), + )}
)} @@ -382,14 +733,33 @@ export const ChatComposer: FC = () => { value={input} onChange={handleInput} onKeyDown={handleKeyDown} - placeholder="Ask me anything (@ to mention, drop to attach)" + onPaste={handlePaste} + placeholder="Ask me anything (/ for commands, @ to mention)" className="max-h-40 min-h-10 w-full resize-none bg-transparent px-4 py-2 text-sm outline-none placeholder:text-muted-foreground" autoFocus rows={1} /> )} -
+
+ {/* Model & settings selector */} +
+ +
+ {isStreaming ? ( ("typing"); - - // Reset when deactivated - useEffect(() => { - if (!active) { - setText(""); - setWordIndex(0); - setPhase("typing"); - } - }, [active]); - - const tick = useCallback(() => { - const current = words[wordIndex]; - - if (phase === "typing") { - if (text.length < current.length) { - setText(current.slice(0, text.length + 1)); - } else { - setPhase("pausing"); - } - } else if (phase === "pausing") { - setPhase("deleting"); - } else if (phase === "deleting") { - if (text.length > 0) { - setText(current.slice(0, text.length - 1)); - } else { - setWordIndex((prev) => (prev + 1) % words.length); - setPhase("typing"); - } - } - }, [text, wordIndex, phase, words]); +const StreamingIndicator: FC = memo(() => { + const [elapsed, setElapsed] = useState(0); + const startRef = useRef(Date.now()); useEffect(() => { - if (!active) return; + startRef.current = Date.now(); + setElapsed(0); + const timer = setInterval(() => { + setElapsed(Math.floor((Date.now() - startRef.current) / 1000)); + }, 1000); + return () => clearInterval(timer); + }, []); - const delay = - phase === "typing" ? typeSpeed : phase === "deleting" ? deleteSpeed : pauseDelay; + return ( +
+
+ + + +
+ + Thinking... + {elapsed >= 3 && ( + {elapsed}s + )} + +
+ ); +}); - const timeout = setTimeout(tick, delay); - return () => clearTimeout(timeout); - }, [active, tick, phase, typeSpeed, deleteSpeed, pauseDelay]); - - return { text, phase }; -} +// ─── Chat Messages (main component) ─── export const ChatMessages: FC = () => { const messages = useClaudeChatStore((s) => s.messages); const isStreaming = useClaudeChatStore((s) => s.isStreaming); const viewportRef = useRef(null); - const { text: thinkingText, phase: thinkingPhase } = useTypewriter(THINKING_MESSAGES, isStreaming); + const shouldAutoScrollRef = useRef(true); + const userHasScrolledRef = useRef(false); // Build a map of tool_use_id → tool_result for inline display const toolResultMap = useMemo(() => { const map = new Map(); for (const msg of messages) { - if (msg.type === "user" && msg.message?.content) { + if (msg.type === "user" && Array.isArray(msg.message?.content)) { for (const block of msg.message.content) { if (block.type === "tool_result" && block.tool_use_id) { map.set(block.tool_use_id, block); @@ -96,7 +65,7 @@ export const ChatMessages: FC = () => { // Collect all assistant text for dedup against result const assistantTexts = new Set(); for (const msg of messages) { - if (msg.type === "assistant" && msg.message?.content) { + if (msg.type === "assistant" && Array.isArray(msg.message?.content)) { for (const block of msg.message.content) { if (block.type === "text" && block.text) { assistantTexts.add(block.text.trim()); @@ -106,18 +75,16 @@ export const ChatMessages: FC = () => { } return messages.filter((msg) => { - // Skip system:init if (msg.type === "system" && msg.subtype === "init") return false; - // Skip non-displayable event types (rate_limit_event, etc.) if (msg.type !== "user" && msg.type !== "assistant" && msg.type !== "result") return false; - // Skip user messages that only contain tool_results if (msg.type === "user" && msg.message?.content) { - const hasOnlyToolResults = msg.message.content.every( - (b) => b.type === "tool_result" - ); - if (hasOnlyToolResults) return false; + if (Array.isArray(msg.message.content)) { + const hasOnlyToolResults = msg.message.content.every( + (b: any) => b.type === "tool_result" + ); + if (hasOnlyToolResults) return false; + } } - // Skip result message if its text duplicates an assistant message if (msg.type === "result" && msg.result) { if (assistantTexts.has(msg.result.trim())) return false; } @@ -125,16 +92,41 @@ export const ChatMessages: FC = () => { }); }, [messages]); - // Auto-scroll to bottom on new messages or message updates + // Auto-scroll to bottom (only if user hasn't scrolled up) useEffect(() => { - if (viewportRef.current) { - viewportRef.current.scrollTop = viewportRef.current.scrollHeight; + if (shouldAutoScrollRef.current && viewportRef.current) { + viewportRef.current.scrollTo({ + top: viewportRef.current.scrollHeight, + behavior: "smooth", + }); } }, [displayMessages]); + // Reset auto-scroll when streaming stops + useEffect(() => { + if (!isStreaming) { + shouldAutoScrollRef.current = true; + userHasScrolledRef.current = false; + } + }, [isStreaming]); + + const handleScroll = () => { + if (!viewportRef.current) return; + const el = viewportRef.current; + const isAtBottom = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50; + if (!isAtBottom) { + userHasScrolledRef.current = true; + shouldAutoScrollRef.current = false; + } else if (userHasScrolledRef.current) { + shouldAutoScrollRef.current = true; + userHasScrolledRef.current = false; + } + }; + return (
{displayMessages.length === 0 && !isStreaming && ( @@ -151,23 +143,7 @@ export const ChatMessages: FC = () => { /> ))} - {isStreaming && ( -
-
- - - -
- - {thinkingText} - - -
- )} + {isStreaming && }
); }; @@ -177,13 +153,7 @@ export const ChatMessages: FC = () => { const MessageBubble: FC<{ message: ClaudeStreamMessage; toolResultMap: Map; -}> = ({ message, toolResultMap }) => { - console.log( - `[chat-msg] type=${message.type} subtype=${message.subtype ?? ""} ` + - `contentTypes=[${message.message?.content?.map((b) => b.type).join(",") ?? "none"}] ` + - `result=${message.result ? `"${message.result.slice(0, 60)}"` : "none"}` - ); - +}> = memo(({ message, toolResultMap }) => { if (message.type === "user") { return ; } @@ -194,15 +164,20 @@ const MessageBubble: FC<{ return ; } return null; -}; +}); // ─── User Message ─── const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => { - const textContent = message.message?.content - ?.filter((b) => b.type === "text") - .map((b) => b.text) - .join("\n"); + const rawContent = message.message?.content; + const textContent = Array.isArray(rawContent) + ? rawContent + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n") + : typeof rawContent === "string" + ? rawContent + : ""; if (!textContent) return null; @@ -304,26 +279,30 @@ const AssistantMessage: FC<{ toolResultMap: Map; }> = ({ message, toolResultMap }) => { const content = message.message?.content; - if (!content || content.length === 0) return null; + if (!Array.isArray(content) || content.length === 0) return null; - // Check if any content block would actually render const hasRenderableContent = content.some( (block) => (block.type === "text" && block.text) || + (block.type === "thinking" && block.thinking) || (block.type === "tool_use" && block.id) ); - if (!hasRenderableContent) { - console.log("[chat-msg] AssistantMessage skipped — no renderable content:", - content.map((b) => ({ type: b.type, hasText: !!b.text, hasId: !!b.id })) - ); - return null; - } + if (!hasRenderableContent) return null; return (
{content.map((block, idx) => { + if (block.type === "thinking" && block.thinking) { + return ( + + ); + } if (block.type === "text" && block.text) { return ( s.pendingAttachments); + + // Auto-open when streaming starts or a new attachment is added useEffect(() => { - if (isStreaming && !isOpen) { + const shouldOpen = isStreaming || pendingAttachments.length > 0; + if (shouldOpen && !isOpen) { setIsOpen(true); const parent = containerRef.current?.parentElement; const maxHeight = parent ? parent.clientHeight * 0.5 : 400; @@ -42,7 +45,7 @@ export function ClaudeChatDrawer() { panelRef.current.style.height = `${maxHeight}px`; } } - }, [isStreaming, isOpen]); + }, [isStreaming, isOpen, pendingAttachments]); const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); diff --git a/apps/desktop/src/components/claude-chat/slash-command-picker.tsx b/apps/desktop/src/components/claude-chat/slash-command-picker.tsx new file mode 100644 index 0000000..8516375 --- /dev/null +++ b/apps/desktop/src/components/claude-chat/slash-command-picker.tsx @@ -0,0 +1,159 @@ +import { type FC, useEffect, useMemo, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface SlashCommand { + id: string; + name: string; + full_command: string; + scope: string; + namespace: string | null; + file_path: string; + content: string; + description: string | null; + allowed_tools: string[]; + has_bash_commands: boolean; + has_file_references: boolean; + accepts_arguments: boolean; +} + +interface SlashCommandPickerProps { + projectPath: string | null; + query: string; + selectedIndex: number; + 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 ; +} + +export const SlashCommandPicker: FC = ({ + projectPath, + query, + selectedIndex, + onSelect, + onClose, +}) => { + const [commands, setCommands] = useState([]); + const listRef = useRef(null); + + useEffect(() => { + invoke("slash_commands_list", { + projectPath: projectPath ?? undefined, + }) + .then(setCommands) + .catch(() => setCommands([])); + }, [projectPath]); + + const filtered = useMemo(() => { + const q = query.toLowerCase(); + if (!q) return commands; + + const matched = 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; + }); + + matched.sort((a, b) => { + const aExact = a.name.toLowerCase() === q; + const bExact = b.name.toLowerCase() === q; + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + const aStarts = a.name.toLowerCase().startsWith(q); + const bStarts = b.name.toLowerCase().startsWith(q); + if (aStarts && !bStarts) return -1; + if (!aStarts && bStarts) return 1; + return a.name.localeCompare(b.name); + }); + + return matched; + }, [query, commands]); + + // Scroll active item into view + useEffect(() => { + if (listRef.current) { + const active = listRef.current.querySelector("[data-active=true]"); + active?.scrollIntoView({ block: "nearest" }); + } + }, [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) => ( + + ))} +
+ + ↑↓ Navigate · Enter Select · Esc Close + +
+
+ ); +}; + +// 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/claude-chat/tool-widgets.tsx b/apps/desktop/src/components/claude-chat/tool-widgets.tsx index 31f589d..b355d83 100644 --- a/apps/desktop/src/components/claude-chat/tool-widgets.tsx +++ b/apps/desktop/src/components/claude-chat/tool-widgets.tsx @@ -1,16 +1,23 @@ import { type FC, useState } from "react"; import { + BotIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, + CircleDotIcon, + CircleIcon, + ClockIcon, FileEditIcon, FileIcon, FileOutputIcon, + ListTodoIcon, LoaderIcon, + MessageCircleQuestionIcon, + SparklesIcon, TerminalIcon, WrenchIcon, } from "lucide-react"; -import type { ContentBlock } from "@/stores/claude-chat-store"; +import { useClaudeChatStore, type ContentBlock } from "@/stores/claude-chat-store"; interface ToolWidgetProps { toolUse: ContentBlock; @@ -26,6 +33,8 @@ export const ToolWidget: FC = ({ toolUse, toolResult }) => { if (name === "bash") return ; if (name === "glob") return ; if (name === "grep") return ; + if (name === "askuserquestion") return ; + if (name === "todowrite") return ; return ; }; @@ -48,8 +57,8 @@ const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result return (
- - + + {result ? "Wrote" : "Writing"}{" "} {input?.file_path} @@ -70,8 +79,8 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result } onClick={() => setExpanded(!expanded)} > - - + + {result ? "Edited" : "Editing"}{" "} {input?.file_path} @@ -97,8 +106,8 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result } return (
- - + + {result ? "Read" : "Reading"}{" "} {input?.file_path} @@ -121,8 +130,8 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result } onClick={() => setExpanded(!expanded)} > - - $ {truncate(command, 80)} + + $ {truncate(command, 80)} {result && ( expanded ? @@ -146,8 +155,8 @@ const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result } return (
- - + + {result ? "Searched" : "Searching"}{" "} {input?.pattern} @@ -161,8 +170,8 @@ const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result } return (
- - + + {result ? "Grepped" : "Grepping"}{" "} {input?.pattern} @@ -170,6 +179,180 @@ const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result } ); }; +// ─── AskUserQuestion Widget ─── + +const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => { + const questions: any[] = input?.questions || []; + const [answered, setAnswered] = useState(false); + + // In -p mode, AskUserQuestion always errors because CLI can't prompt interactively. + // The process is killed when AskUserQuestion is detected, so result may be undefined. + // Options are clickable when there's no result or an error result. + const isStreaming = useClaudeChatStore((s) => s.isStreaming); + const needsUserAnswer = !answered && !isStreaming && (!result || result.is_error); + + const handleOptionClick = (question: string, label: string) => { + const { sendPrompt, isStreaming } = useClaudeChatStore.getState(); + if (isStreaming) return; + setAnswered(true); + sendPrompt(`${label}`); + }; + + if (questions.length === 0) { + return ( +
+ + + + {result ? "Asked question" : "Asking question..."} + +
+ ); + } + + // Determine header state + const headerLabel = isStreaming + ? "Waiting for answer..." + : needsUserAnswer + ? "Choose an option" + : answered + ? "Answer sent" + : "Question answered"; + + return ( +
+
+ {needsUserAnswer ? ( + + ) : ( + <> + + + + )} + + {headerLabel} + +
+
+ {questions.map((q: any, qIdx: number) => ( +
+ {q.header && ( + + {q.header} + + )} +

{q.question}

+
+ {q.options?.map((opt: any, oIdx: number) => ( + + ))} +
+
+ ))} +
+
+ ); +}; + +// ─── TodoWrite Widget ─── + +const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => { + const [expanded, setExpanded] = useState(true); + const todos: any[] = input?.todos || []; + + const statusIcon = (status: string) => { + switch (status) { + case "completed": + return ; + case "in_progress": + return ; + default: + return ; + } + }; + + const completedCount = todos.filter((t) => t.status === "completed").length; + + return ( +
+ + {expanded && todos.length > 0 && ( +
+ {todos.map((todo, idx) => ( +
+ {statusIcon(todo.status)} + + {todo.status === "in_progress" + ? (todo.activeForm || todo.content) + : todo.content} + +
+ ))} +
+ )} +
+ ); +}; + // ─── Generic Widget ─── const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = ({ @@ -206,6 +389,39 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = ( ); }; +// ─── Thinking Widget ─── + +export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ thinking }) => { + const [expanded, setExpanded] = useState(false); + const trimmed = thinking.trim(); + + return ( +
+ + {expanded && ( +
+
+            {trimmed}
+          
+
+ )} +
+ ); +}; + // ─── Helpers ─── function truncate(str: string, max: number): string { diff --git a/apps/desktop/src/components/claude-setup.tsx b/apps/desktop/src/components/claude-setup.tsx new file mode 100644 index 0000000..ef3d93d --- /dev/null +++ b/apps/desktop/src/components/claude-setup.tsx @@ -0,0 +1,405 @@ +import { useEffect, useRef } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { + DownloadIcon, + LogInIcon, + LoaderIcon, + CheckCircle2Icon, + CheckIcon, + AlertCircleIcon, + RefreshCwIcon, + TerminalIcon, + CircleIcon, + ChevronRightIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useClaudeSetupStore, type StepInfo } from "@/stores/claude-setup-store"; +import { cn } from "@/lib/utils"; + +// ─── Event Hooks ─── + +function useInstallEvents() { + const isInstalling = useClaudeSetupStore((s) => s.isInstalling); + + useEffect(() => { + if (!isInstalling) return; + + const unlisteners: UnlistenFn[] = []; + let cancelled = false; + + // Synthetic timer: advance to "installing" after 3s if still on downloading + const timer = setTimeout(() => { + if (cancelled) return; + const store = useClaudeSetupStore.getState(); + const downloadStep = store.installSteps.find((s) => s.id === "downloading"); + if (downloadStep?.status === "active") { + store._advanceInstallStep("installing"); + } + }, 3000); + + (async () => { + const unlistenOutput = await listen("install-output", (event) => { + if (cancelled) return; + const store = useClaudeSetupStore.getState(); + const line = event.payload; + store._appendInstallLog(line); + + // Parse output for step advancement + const lower = line.toLowerCase(); + if (lower.includes("setting up") || lower.includes("installing")) { + store._advanceInstallStep("installing"); + } + if (lower.includes("complete") || lower.includes("successfully") || line.includes("✅")) { + store._advanceInstallStep("verifying"); + } + }); + + const unlistenError = await listen("install-error", (event) => { + if (cancelled) return; + useClaudeSetupStore.getState()._appendInstallLog(event.payload); + }); + + const unlistenComplete = await listen("install-complete", (event) => { + if (cancelled) return; + clearTimeout(timer); + useClaudeSetupStore.getState()._finishInstall(event.payload); + }); + + if (cancelled) { + unlistenOutput(); + unlistenError(); + unlistenComplete(); + return; + } + + unlisteners.push(unlistenOutput, unlistenError, unlistenComplete); + })(); + + return () => { + cancelled = true; + clearTimeout(timer); + for (const u of unlisteners) u(); + }; + }, [isInstalling]); +} + +function useLoginEvents() { + const isLoggingIn = useClaudeSetupStore((s) => s.isLoggingIn); + + useEffect(() => { + if (!isLoggingIn) return; + + const unlisteners: UnlistenFn[] = []; + let cancelled = false; + + // Advance to "waiting-auth" after 1.5s + const timer = setTimeout(() => { + if (cancelled) return; + useClaudeSetupStore.getState()._advanceLoginStep("waiting-auth"); + }, 1500); + + (async () => { + const unlistenOutput = await listen("login-output", (event) => { + if (cancelled) return; + // Any output means browser is open, advance to waiting + useClaudeSetupStore.getState()._advanceLoginStep("waiting-auth"); + }); + + const unlistenError = await listen("login-error", () => { + // ignore stderr for login + }); + + const unlistenComplete = await listen("login-complete", (event) => { + if (cancelled) return; + clearTimeout(timer); + useClaudeSetupStore.getState()._finishLogin(event.payload); + }); + + if (cancelled) { + unlistenOutput(); + unlistenError(); + unlistenComplete(); + return; + } + + unlisteners.push(unlistenOutput, unlistenError, unlistenComplete); + })(); + + return () => { + cancelled = true; + clearTimeout(timer); + for (const u of unlisteners) u(); + }; + }, [isLoggingIn]); +} + +// ─── Sub-components ─── + +function StepRow({ step }: { step: StepInfo }) { + return ( +
+ {step.status === "complete" && ( + + )} + {step.status === "active" && ( + + )} + {step.status === "pending" && ( + + )} + {step.status === "error" && ( + + )} + + {step.label} + +
+ ); +} + +function InstallLogOutput() { + const logs = useClaudeSetupStore((s) => s.installLogs); + const visible = useClaudeSetupStore((s) => s.installLogsVisible); + const toggle = useClaudeSetupStore((s) => s.toggleInstallLogs); + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current && visible) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs, visible]); + + return ( +
+ +
+
+ {logs.length === 0 ? ( + Waiting for output... + ) : ( + logs.map((line, i) => ( +
+ {line} +
+ )) + )} +
+
+
+ ); +} + +// ─── Main Component ─── + +export function ClaudeSetup() { + const status = useClaudeSetupStore((s) => s.status); + const isInstalling = useClaudeSetupStore((s) => s.isInstalling); + const isLoggingIn = useClaudeSetupStore((s) => s.isLoggingIn); + const error = useClaudeSetupStore((s) => s.error); + const version = useClaudeSetupStore((s) => s.version); + const accountEmail = useClaudeSetupStore((s) => s.accountEmail); + const install = useClaudeSetupStore((s) => s.install); + const login = useClaudeSetupStore((s) => s.login); + const checkStatus = useClaudeSetupStore((s) => s.checkStatus); + const installSteps = useClaudeSetupStore((s) => s.installSteps); + const loginSteps = useClaudeSetupStore((s) => s.loginSteps); + + useInstallEvents(); + useLoginEvents(); + + if (status === "checking") { + return ( +
+ + + Checking Claude Code... + +
+ ); + } + + if (status === "ready") { + return ( +
+ +
+

Claude Code Ready

+

+ {[version, accountEmail].filter(Boolean).join(" · ")} +

+
+
+ ); + } + + // Installation in progress + if (isInstalling) { + return ( +
+
+ +

Installing Claude Code

+
+ +
+ {installSteps.map((step) => ( + + ))} +
+ + +
+ ); + } + + // Login in progress + if (isLoggingIn) { + return ( +
+
+ +

Signing in to Claude

+
+ +
+ {loginSteps.map((step) => ( + + ))} +
+ +

+ Complete the sign-in in your browser to continue. +

+
+ ); + } + + if (status === "error") { + const hasInstallSteps = installSteps.length > 0; + + return ( +
+
+ +

+ {hasInstallSteps ? "Installation Failed" : "Setup Error"} +

+
+ + {hasInstallSteps && ( +
+ {installSteps.map((step) => ( + + ))} +
+ )} + + {error && ( +

+ {error} +

+ )} + + {hasInstallSteps && } + + +
+ ); + } + + if (status === "not-installed") { + return ( +
+
+ +
+

Claude Code Required

+

+ ClaudePrism needs Claude Code CLI to power AI features. +

+
+
+ +

+ Installs to ~/.local/bin/claude +

+
+ ); + } + + if (status === "not-authenticated") { + return ( +
+
+ +
+

Sign in to Claude

+

+ Authenticate with your Anthropic account to continue. +

+
+
+ {version && ( +

+ Claude Code {version} installed +

+ )} + +
+ ); + } + + return null; +} diff --git a/apps/desktop/src/components/project-picker.tsx b/apps/desktop/src/components/project-picker.tsx index f92d205..8360a83 100644 --- a/apps/desktop/src/components/project-picker.tsx +++ b/apps/desktop/src/components/project-picker.tsx @@ -1,24 +1,44 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { FolderOpenIcon, FolderPlusIcon, ClockIcon, XIcon, + FileTextIcon, + SparklesIcon, } from "lucide-react"; import { useProjectStore } from "@/stores/project-store"; import { useDocumentStore } from "@/stores/document-store"; +import { useClaudeSetupStore } from "@/stores/claude-setup-store"; import { Button } from "@/components/ui/button"; -import { ProjectWizard } from "./project-wizard"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { ProjectWizard, type CreationMode } from "./project-wizard"; +import { ClaudeSetup } from "./claude-setup"; export function ProjectPicker() { - const [showWizard, setShowWizard] = useState(false); + const [showModeDialog, setShowModeDialog] = useState(false); + const [wizardMode, setWizardMode] = useState(null); const recentProjects = useProjectStore((s) => s.recentProjects); const addRecentProject = useProjectStore((s) => s.addRecentProject); const removeRecentProject = useProjectStore((s) => s.removeRecentProject); const openProject = useDocumentStore((s) => s.openProject); + const claudeStatus = useClaudeSetupStore((s) => s.status); + const checkClaudeStatus = useClaudeSetupStore((s) => s.checkStatus); + const isClaudeReady = claudeStatus === "ready"; + + useEffect(() => { + checkClaudeStatus(); + }, [checkClaudeStatus]); + const handleOpenFolder = async () => { const selected = await open({ directory: true, @@ -36,8 +56,18 @@ export function ProjectPicker() { await openProject(path); }; - if (showWizard) { - return setShowWizard(false)} />; + const handleSelectMode = (mode: CreationMode) => { + setShowModeDialog(false); + setWizardMode(mode); + }; + + if (wizardMode) { + return ( + setWizardMode(null)} + /> + ); } return ( @@ -51,12 +81,15 @@ export function ProjectPicker() {

-
+ {!isClaudeReady && } + +
)}
+ + {/* New Project mode selection dialog */} + + + + Create New Project + + How would you like to start? + + +
+ + + +
+
+
); } diff --git a/apps/desktop/src/components/project-wizard.tsx b/apps/desktop/src/components/project-wizard.tsx index f150f80..3696b9d 100644 --- a/apps/desktop/src/components/project-wizard.tsx +++ b/apps/desktop/src/components/project-wizard.tsx @@ -1,22 +1,19 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { documentDir } from "@tauri-apps/api/path"; import { ArrowLeftIcon, - ArrowRightIcon, - FileTextIcon, - UserIcon, - LayoutIcon, - BookOpenIcon, - MailIcon, - MonitorIcon, - BookIcon, - FileIcon, FolderOpenIcon, PaperclipIcon, XIcon, SparklesIcon, - GraduationCapIcon, + UploadIcon, + ChevronDownIcon, + FileTextIcon, + MapPinIcon, + Loader2Icon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -25,395 +22,103 @@ import { useProjectStore } from "@/stores/project-store"; import { useDocumentStore } from "@/stores/document-store"; import { useClaudeChatStore } from "@/stores/claude-chat-store"; import { exists, join } from "@/lib/tauri/fs"; +import { getTemplateById, getTemplateSkeleton, BIB_TEMPLATE } from "@/lib/template-registry"; +import { TemplateGallery } from "@/components/template-gallery"; -// ─── Template Definitions ─── +// ─── Helpers ─── -interface Template { - id: string; - name: string; - description: string; - icon: React.ReactNode; - documentClass: string; - mainFileName: string; - content: string; +function randomProjectName(): string { + const adjectives = ["swift", "bright", "calm", "bold", "keen", "warm", "pure", "vast", "deep", "fair"]; + const nouns = ["paper", "draft", "thesis", "note", "study", "essay", "report", "brief", "folio", "opus"]; + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + const id = Math.random().toString(36).slice(2, 6); + return `${adj}-${noun}-${id}`; } -const TEMPLATES: Template[] = [ - { - id: "paper", - name: "Research Paper", - description: "Academic paper with abstract, sections, references", - icon: , - documentClass: "article", - mainFileName: "main.tex", - content: `\\documentclass[12pt]{article} -\\usepackage[utf8]{inputenc} -\\usepackage[T1]{fontenc} -\\usepackage{amsmath,amssymb} -\\usepackage{graphicx} -\\usepackage[margin=1in]{geometry} -\\usepackage{hyperref} -\\usepackage{booktabs} -\\usepackage{natbib} - -\\title{Title} -\\author{Author Name} -\\date{\\today} - -\\begin{document} - -\\maketitle - -\\begin{abstract} -Your abstract here. -\\end{abstract} - -\\section{Introduction} - -\\section{Related Work} - -\\section{Method} - -\\section{Results} - -\\section{Conclusion} - -\\bibliographystyle{plainnat} -\\bibliography{references} - -\\end{document} -`, - }, - { - id: "cv", - name: "CV / Resume", - description: "Professional curriculum vitae", - icon: , - documentClass: "article", - mainFileName: "main.tex", - content: `\\documentclass[11pt,a4paper]{article} -\\usepackage[utf8]{inputenc} -\\usepackage[T1]{fontenc} -\\usepackage[margin=0.8in]{geometry} -\\usepackage{hyperref} -\\usepackage{enumitem} -\\usepackage{titlesec} - -\\titleformat{\\section}{\\large\\bfseries}{}{0em}{}[\\titlerule] -\\titlespacing{\\section}{0pt}{12pt}{6pt} - -\\pagestyle{empty} - -\\begin{document} - -\\begin{center} - {\\LARGE\\bfseries Your Name}\\\\[4pt] - your.email@example.com \\quad | \\quad City, Country -\\end{center} - -\\section{Education} - -\\section{Experience} - -\\section{Skills} - -\\section{Publications} - -\\end{document} -`, - }, - { - id: "poster", - name: "Poster", - description: "Conference or research poster", - icon: , - documentClass: "a0poster", - mainFileName: "main.tex", - content: `\\documentclass[a1paper,portrait]{a0poster} -\\usepackage[utf8]{inputenc} -\\usepackage[T1]{fontenc} -\\usepackage{amsmath,amssymb} -\\usepackage{graphicx} -\\usepackage{multicol} -\\usepackage[margin=2cm]{geometry} -\\usepackage{xcolor} - -\\begin{document} - -\\begin{center} - {\\VERYHuge\\bfseries Poster Title}\\\\[1cm] - {\\LARGE Author Name \\quad Institution} -\\end{center} - -\\vspace{1cm} - -\\begin{multicols}{2} - -\\section*{Introduction} - -\\section*{Methods} - -\\section*{Results} - -\\section*{Conclusions} - -\\section*{References} - -\\end{multicols} - -\\end{document} -`, - }, - { - id: "thesis", - name: "Thesis", - description: "Dissertation or thesis with chapters", - icon: , - documentClass: "report", - mainFileName: "main.tex", - content: `\\documentclass[12pt,a4paper]{report} -\\usepackage[utf8]{inputenc} -\\usepackage[T1]{fontenc} -\\usepackage{amsmath,amssymb} -\\usepackage{graphicx} -\\usepackage[margin=1in]{geometry} -\\usepackage{hyperref} -\\usepackage{booktabs} -\\usepackage{natbib} -\\usepackage{setspace} - -\\onehalfspacing - -\\title{Thesis Title} -\\author{Author Name} -\\date{\\today} - -\\begin{document} - -\\maketitle - -\\begin{abstract} -Your abstract here. -\\end{abstract} - -\\tableofcontents - -\\chapter{Introduction} - -\\chapter{Literature Review} - -\\chapter{Methodology} - -\\chapter{Results} - -\\chapter{Discussion} - -\\chapter{Conclusion} - -\\bibliographystyle{plainnat} -\\bibliography{references} - -\\end{document} -`, - }, - { - id: "presentation", - name: "Presentation", - description: "Beamer slides for talks and lectures", - icon: , - documentClass: "beamer", - mainFileName: "main.tex", - content: `\\documentclass{beamer} -\\usepackage[utf8]{inputenc} -\\usepackage[T1]{fontenc} -\\usepackage{amsmath,amssymb} -\\usepackage{graphicx} - -\\usetheme{Madrid} - -\\title{Presentation Title} -\\author{Author Name} -\\institute{Institution} -\\date{\\today} - -\\begin{document} - -\\begin{frame} - \\titlepage -\\end{frame} - -\\begin{frame}{Outline} - \\tableofcontents -\\end{frame} - -\\section{Introduction} -\\begin{frame}{Introduction} - Content here. -\\end{frame} - -\\section{Main Content} -\\begin{frame}{Main Content} - Content here. -\\end{frame} - -\\section{Conclusion} -\\begin{frame}{Conclusion} - Content here. -\\end{frame} - -\\end{document} -`, - }, - { - id: "letter", - name: "Letter", - description: "Formal or cover letter", - icon: , - documentClass: "letter", - mainFileName: "main.tex", - content: `\\documentclass[12pt]{letter} -\\usepackage[utf8]{inputenc} -\\usepackage[T1]{fontenc} -\\usepackage[margin=1in]{geometry} -\\usepackage{hyperref} - -\\signature{Your Name} -\\address{Your Address \\\\ City, Country} - -\\begin{document} - -\\begin{letter}{Recipient Name \\\\ Recipient Address \\\\ City, Country} - -\\opening{Dear Recipient,} - -Your letter content here. - -\\closing{Sincerely,} - -\\end{letter} - -\\end{document} -`, - }, - { - id: "book", - name: "Book", - description: "Multi-chapter book or manuscript", - icon: , - documentClass: "book", - mainFileName: "main.tex", - content: `\\documentclass[12pt,a4paper]{book} -\\usepackage[utf8]{inputenc} -\\usepackage[T1]{fontenc} -\\usepackage{amsmath,amssymb} -\\usepackage{graphicx} -\\usepackage[margin=1in]{geometry} -\\usepackage{hyperref} - -\\title{Book Title} -\\author{Author Name} -\\date{\\today} - -\\begin{document} - -\\frontmatter -\\maketitle -\\tableofcontents - -\\mainmatter - -\\chapter{First Chapter} - -\\chapter{Second Chapter} - -\\backmatter - -\\end{document} -`, - }, - { - id: "blank", - name: "Blank", - description: "Minimal template to start from scratch", - icon: , - documentClass: "article", - mainFileName: "main.tex", - content: `\\documentclass[12pt]{article} -\\usepackage[utf8]{inputenc} -\\usepackage[T1]{fontenc} - -\\begin{document} - -% Start writing here. - -\\end{document} -`, - }, -]; - -const BIB_TEMPLATE = `% Add your references here -% Example: -% @article{key, -% author = {Author Name}, -% title = {Article Title}, -% journal = {Journal Name}, -% year = {2024}, -% } -`; - // ─── Wizard Component ─── +export type CreationMode = "template" | "scratch"; + interface ProjectWizardProps { + mode: CreationMode; onBack: () => void; } -export function ProjectWizard({ onBack }: ProjectWizardProps) { - const [step, setStep] = useState<1 | 2>(1); - const [selectedTemplate, setSelectedTemplate] = useState(null); +export function ProjectWizard({ mode, onBack }: ProjectWizardProps) { + // ── Template mode: just show the gallery ── + // The TemplatePreview modal inside the gallery handles details + creation. + if (mode === "template") { + return ( +
+
+ + Choose a Template +
+
+ +
+
+ ); + } + + // ── Scratch mode: inline details form ── + return ; +} + +// ─── Scratch mode form (no template preview) ─── + +function ScratchForm({ onBack }: { onBack: () => void }) { const [purpose, setPurpose] = useState(""); const [attachments, setAttachments] = useState([]); const [projectFolder, setProjectFolder] = useState(null); - const [projectName, setProjectName] = useState(""); + const [projectName, setProjectName] = useState(randomProjectName); const [isCreating, setIsCreating] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const [refFilesOpen, setRefFilesOpen] = useState(false); + const [locationOpen, setLocationOpen] = useState(false); + + const textareaRef = useRef(null); const addRecentProject = useProjectStore((s) => s.addRecentProject); + const lastProjectFolder = useProjectStore((s) => s.lastProjectFolder); + const setLastProjectFolder = useProjectStore((s) => s.setLastProjectFolder); const openProject = useDocumentStore((s) => s.openProject); - const template = TEMPLATES.find((t) => t.id === selectedTemplate); + const template = getTemplateById("blank")!; - const handleSelectTemplate = (id: string) => { - setSelectedTemplate(id); - setStep(2); - }; + useEffect(() => { + const timer = setTimeout(() => textareaRef.current?.focus(), 100); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (projectFolder) return; + if (lastProjectFolder) { + setProjectFolder(lastProjectFolder); + } else { + documentDir().then((dir) => setProjectFolder(dir)).catch(() => {}); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleChooseFolder = useCallback(async () => { - const selected = await open({ - directory: true, - multiple: false, - title: "Choose Location for New Project", - }); + const selected = await open({ directory: true, multiple: false, title: "Choose Location for New Project" }); if (selected) { setProjectFolder(selected); - if (!projectName) { - setProjectName(selected.split("/").pop() || "my-project"); - } + setLastProjectFolder(selected); } - }, [projectName]); + }, [setLastProjectFolder]); const handleAddAttachments = useCallback(async () => { const selected = await open({ multiple: true, title: "Add Reference Files", - filters: [ - { - name: "Documents & Images", - extensions: [ - "pdf", "tex", "bib", "txt", "md", - "png", "jpg", "jpeg", "gif", "svg", - "csv", "tsv", "json", - ], - }, - ], + filters: [{ + name: "Documents & Images", + extensions: ["pdf", "tex", "bib", "txt", "md", "png", "jpg", "jpeg", "gif", "svg", "csv", "tsv", "json"], + }], }); if (selected) { const paths = Array.isArray(selected) ? selected : [selected]; @@ -425,24 +130,49 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) { setAttachments((prev) => prev.filter((p) => p !== path)); }; + // Drag-drop + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + + getCurrentWebview() + .onDragDropEvent((event) => { + if (cancelled) return; + const { type } = event.payload; + if (type === "enter") { + setIsDragOver(true); + setRefFilesOpen(true); + } else if (type === "drop") { + setIsDragOver(false); + const paths = (event.payload as { paths: string[] }).paths; + if (paths?.length > 0) { + setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]); + } + } else if (type === "leave") { + setIsDragOver(false); + } + }) + .then((fn) => { if (cancelled) fn(); else unlisten = fn; }) + .catch(() => {}); + + return () => { cancelled = true; unlisten?.(); }; + }, []); + const handleCreate = async () => { if (!template || !projectFolder || !projectName.trim()) return; setIsCreating(true); try { - // Create project directory const projectPath = await join(projectFolder, projectName.trim()); await mkdir(projectPath, { recursive: true }).catch(() => {}); - // Write main.tex const mainTexPath = await join(projectPath, template.mainFileName); const mainExists = await exists(mainTexPath); if (!mainExists) { - await writeTextFile(mainTexPath, template.content); + await writeTextFile(mainTexPath, getTemplateSkeleton(template)); } - // Write references.bib for templates that use bibliography - if (["paper", "thesis"].includes(template.id)) { + if (template.hasBibliography) { const bibPath = await join(projectPath, "references.bib"); const bibExists = await exists(bibPath); if (!bibExists) { @@ -450,33 +180,44 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) { } } - // Import attachments into project if (attachments.length > 0) { const attachmentsDir = await join(projectPath, "attachments"); await mkdir(attachmentsDir, { recursive: true }).catch(() => {}); } - // Build the initial prompt for Claude if (purpose.trim()) { const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean); - let prompt = `I just created a new ${template.name} project using a ${template.documentClass} template.\n\n`; - prompt += `Here is what I want to create:\n${purpose.trim()}\n\n`; - if (attachmentNames.length > 0) { - prompt += `I've included these reference files in the attachments/ folder: ${attachmentNames.join(", ")}\n`; - prompt += `Please review them and incorporate relevant information.\n\n`; - } - prompt += `Please customize the template to match my requirements. Update the content, title, author, and structure as needed. Make it a complete, well-structured document ready for me to refine.`; + const attachmentSection = attachmentNames.length > 0 + ? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n` + : ""; + + const prompt = [ + `## New ${template.name} Project`, + "", + `| | |`, + `|---|---|`, + `| **Template** | \`${template.documentClass}\` |`, + `| **File** | \`${template.mainFileName}\` |`, + "", + `> The file currently contains only the LaTeX preamble (packages, styling, custom commands) with an empty document body.`, + "", + `### What I want to create`, + "", + purpose.trim(), + attachmentSection, + `### Instructions`, + "", + `Please generate the full document content based on my description. Keep the existing preamble and fill in the document body (between \`\\begin{document}\` and \`\\end{document}\`) with appropriate title, author, sections, and content. Make it a complete, well-structured **${template.name.toLowerCase()}** ready for me to refine.`, + ].join("\n"); - // Set as pending so it fires after workspace initializes useClaudeChatStore.getState().newSession(); useClaudeChatStore.getState().setPendingInitialPrompt(prompt); } - // Open the project + setLastProjectFolder(projectFolder); addRecentProject(projectPath); await openProject(projectPath); - // Import attachments after project is open if (attachments.length > 0) { await useDocumentStore.getState().importFiles(attachments, "attachments"); } @@ -491,121 +232,176 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) { return (
- {/* Header — padded top for macOS overlay titlebar */} -
- -
- - - {step === 1 ? "Choose a Template" : "Project Details"} - -
-
-
= 1 ? "bg-foreground" : "bg-muted-foreground/30"}`} /> -
= 2 ? "bg-foreground" : "bg-muted-foreground/30"}`} /> -
+ New Document
- {/* Content */} + {/* Form */}
- {step === 1 ? ( - - ) : ( -
- {/* Selected template indicator */} - {template && ( +
+ {/* Purpose */} +
+
+ +

+ Describe your document and Claude will generate tailored content. +

+
+