From d35a6a0ef366f86aeba28c06f40e44cd29cf1f07 Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Tue, 28 Jul 2026 06:29:04 +0800 Subject: Codex/agent-neutral setup, terminal IME fix, hide own overlay cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Write AGENTS.md (the cross-tool agent-instructions standard) at the project root alongside .claude/CLAUDE.md, generated from one guide; includes an Agent Setup section with the project-specific Codex MCP registration command. AGENTS.md is excluded from Overleaf sync. - MCP server resolves the project directory from its own script location when cwd doesn't contain the state file, so clients that launch MCP servers from a global config (Codex) work without special setup - Terminal: swallow the duplicate compositionend commit when switching input sources mid-composition (xterm finalizes on the keydown and then again on compositionend, sending pinyin text twice) - Don't render our own colored overlay cursor — the native caret already marks our position; overlay cursors are collaborators-only (web parity) Co-Authored-By: Claude Fable 5 --- src/main/fileSyncBridge.ts | 8 +++---- src/main/index.ts | 41 ++++++++++++++++++++++++++------ src/mcp/lattex.mjs | 39 +++++++++++++++++++++++------- src/renderer/src/components/Terminal.tsx | 34 ++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts index b7e1979..3072ae6 100644 --- a/src/main/fileSyncBridge.ts +++ b/src/main/fileSyncBridge.ts @@ -207,7 +207,7 @@ export class FileSyncBridge { ignored: [ /(^|[/\\])\../, // dotfiles (also covers .build/ and .claude/) JUNK_EXT_RE, // pure build noise (aux/log/synctex/…) — never project content - /(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/, // App-generated config files + /(?:^|[/\\])(?:CLAUDE\.md|AGENTS\.md|\.mcp\.json)$/, // App-generated config files /(?:^|[/\\])(?:claude-workspace|__MACOSX)(?:[/\\]|$)/ // scratch space + zip junk ] }) @@ -752,7 +752,7 @@ export class FileSyncBridge { // Skip app-generated config files and scratch space that should not be synced const basename = relPath.split('/').pop() || relPath - if (basename === 'CLAUDE.md' || basename === '.mcp.json') return + if (basename === 'CLAUDE.md' || basename === 'AGENTS.md' || basename === '.mcp.json') return if (relPath.startsWith('claude-workspace/') || relPath === 'claude-workspace') return // Layer 1: Skip if bridge is currently writing this file @@ -1412,7 +1412,7 @@ export class FileSyncBridge { // Skip LaTeX build artifacts, dotfiles, app config files, and scratch space if (this.isCompileArtifact(relPath)) continue if (/(^|[/\\])\./.test(relPath)) continue - if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) continue + if (/(?:^|[/\\])(?:CLAUDE\.md|AGENTS\.md|\.mcp\.json)$/.test(relPath)) continue if (relPath.startsWith('claude-workspace/') || relPath === 'claude-workspace') continue bridgeLog(`[FileSyncBridge] orphaned file found: ${relPath}`) @@ -1433,7 +1433,7 @@ export class FileSyncBridge { // Skip LaTeX build artifacts, dotfiles, app config files, and scratch space if (this.isCompileArtifact(relPath)) return if (/(^|[/\\])\./.test(relPath)) return - if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) return + if (/(?:^|[/\\])(?:CLAUDE\.md|AGENTS\.md|\.mcp\.json)$/.test(relPath)) return if (relPath.startsWith('claude-workspace/') || relPath === 'claude-workspace') return // Debounce 1s to let the tool finish writing diff --git a/src/main/index.ts b/src/main/index.ts index f03f8b6..32be5b3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -864,9 +864,13 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => { // Relay collaborator cursor updates to renderer + track for MCP overleafSock.on('serverEvent', (name: string, args: unknown[]) => { if (name === 'clientTracking.clientUpdated') { - sendToRenderer('cursor:remoteUpdate', args[0]) - // Track online user for MCP const u = args[0] as { id: string; user_id?: string; name?: string; email?: string } + // Skip our own echo — the native caret already marks our position; + // colored overlay cursors are for collaborators only (web behavior) + if (!u.id || u.id !== overleafSock?.publicId) { + sendToRenderer('cursor:remoteUpdate', args[0]) + } + // Track online user for MCP (includes ourselves) if (u.id) { mcpOnlineUsers.set(u.id, { name: u.name || u.email?.split('@')[0] || 'User', email: u.email }) writeMcpOnlineUsers() @@ -928,8 +932,9 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => { // Write .mcp.json so Claude Code auto-discovers the MCP server // Dev: use source file. Packaged: copy bundled server into the project // temp dir so .mcp.json never contains a stale App Translocation path. + let mcpServerPath = '' try { - const mcpServerPath = await prepareMcpServerPath(tmpDir) + mcpServerPath = await prepareMcpServerPath(tmpDir) await writeFile(join(tmpDir, '.mcp.json'), JSON.stringify({ mcpServers: { lattex: { @@ -964,7 +969,11 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => { } catch { /* non-fatal */ } const ownerName = [projectResult.project.owner.first_name, projectResult.project.owner.last_name].filter(Boolean).join(' ') - await writeFile(join(tmpDir, '.claude', 'CLAUDE.md'), `# ${projectResult.project.name} — Overleaf Project + // One guide, two consumers: .claude/CLAUDE.md (Claude Code's native + // location) and AGENTS.md at the project root (the cross-tool standard + // read by Codex, Cursor, Gemini CLI, etc.). AGENTS.md is excluded from + // Overleaf sync alongside CLAUDE.md/.mcp.json. + const agentGuide = `# ${projectResult.project.name} — Overleaf Project > **IMPORTANT — MANDATORY FIRST STEPS (do this EVERY conversation before ANY edits):** > @@ -1054,7 +1063,21 @@ The \`claude-workspace/\` directory is your private scratch space. It is **not s - **Scripts** — helper scripts for data processing, bibliography management, etc. **Important**: Always ask the user before running experiments or creating files in \`claude-workspace/\`. This directory persists across sessions for the same project. -`) + +## Agent Setup (MCP) + +The tools above come from LatteX's MCP server (standard stdio MCP — works with any MCP-capable agent): + +- **Claude Code**: auto-configured. \`.mcp.json\` in this directory registers the \`lattex\` server and \`.claude/settings.json\` pre-approves its tools. Just run \`claude\`. +- **Codex CLI**: register the server once for this project: + \`\`\` + codex mcp add lattex -- node "${mcpServerPath}" + \`\`\` + The path is project-specific — re-run this when switching projects. Approve \`lattex\` tool calls when Codex prompts. +- **Any other MCP client**: stdio transport, command \`node "${mcpServerPath}"\`. +` + await writeFile(join(tmpDir, '.claude', 'CLAUDE.md'), agentGuide) + await writeFile(join(tmpDir, 'AGENTS.md'), agentGuide) await writeFile(join(tmpDir, '.claude', 'settings.json'), JSON.stringify({ permissions: { allow: [ @@ -1280,7 +1303,7 @@ ipcMain.handle('cursor:getConnectedUsers', async () => { if (!overleafSock) return [] try { const users = await overleafSock.getConnectedUsers() - // Seed MCP online users map + // Seed MCP online users map (includes ourselves) mcpOnlineUsers.clear() for (const raw of users) { const u = raw as { client_id?: string; first_name?: string; last_name?: string; email?: string } @@ -1290,7 +1313,11 @@ ipcMain.handle('cursor:getConnectedUsers', async () => { } } writeMcpOnlineUsers() - return users + // Exclude our own client — no colored overlay cursor for ourselves + return users.filter((raw) => { + const u = raw as { client_id?: string } + return !u.client_id || u.client_id !== overleafSock?.publicId + }) } catch (e) { console.log('[cursor:getConnectedUsers] error:', e) return [] diff --git a/src/mcp/lattex.mjs b/src/mcp/lattex.mjs index 64520f7..e0cb6cd 100644 --- a/src/mcp/lattex.mjs +++ b/src/mcp/lattex.mjs @@ -3,8 +3,9 @@ // Licensed under AGPL-3.0 - see LICENSE file // MCP Server: LatteX -// Provides tools for Claude Code to interact with the Overleaf project: -// comments, chat, file listing, compilation + debugging +// Provides tools for coding agents (Claude Code, Codex, any MCP client) to +// interact with the Overleaf project: comments, chat, file listing, +// compilation + debugging import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' @@ -13,14 +14,34 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { readFileSync, readdirSync, statSync, writeFileSync, existsSync, unlinkSync } from 'fs' -import { join, relative } from 'path' +import { join, relative, dirname } from 'path' +import { fileURLToPath } from 'url' import https from 'https' +// ── Project directory resolution ────────────────────────────── +// +// Claude Code launches MCP servers with the project as cwd, but other MCP +// clients (Codex reads global config) may not. The packaged server is copied +// into /.lattex/lattex-mcp.mjs, so the script's own location also +// identifies the project. Resolution order: cwd, then script parent dir. + +function resolveProjectDir() { + const cwd = process.cwd() + if (existsSync(join(cwd, '.lattex-mcp.json'))) return cwd + try { + const scriptDir = dirname(fileURLToPath(import.meta.url)) + const candidate = dirname(scriptDir) // /.lattex/.. = + if (existsSync(join(candidate, '.lattex-mcp.json'))) return candidate + } catch { /* fall through */ } + return cwd +} + +const PROJECT_DIR = resolveProjectDir() + // ── State ────────────────────────────────────────────────────── function readState() { - const cwd = process.cwd() - const statePath = join(cwd, '.lattex-mcp.json') + const statePath = join(PROJECT_DIR, '.lattex-mcp.json') try { return JSON.parse(readFileSync(statePath, 'utf-8')) } catch { @@ -847,7 +868,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // ── Project ─────────────────────────────────── case 'list_project_files': { - const cwd = process.cwd() + const cwd = PROJECT_DIR const files = walkDir(cwd, cwd) .filter(f => !f.path.startsWith('.')) @@ -870,7 +891,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { case 'compile_latex': { const mainFile = args?.main_file || null - const cwd = process.cwd() + const cwd = PROJECT_DIR const requestPath = join(cwd, '.lattex-compile-request') const resultPath = join(cwd, '.lattex-compile-result') @@ -1015,7 +1036,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case 'get_online_users': { - const cwd = process.cwd() + const cwd = PROJECT_DIR const usersPath = join(cwd, '.lattex-online-users.json') try { const users = JSON.parse(readFileSync(usersPath, 'utf-8')) @@ -1032,7 +1053,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // ── PDF ────────────────────────────────────────── case 'read_compiled_pdf': { - const cwd = process.cwd() + const cwd = PROJECT_DIR const pdfPath = join(cwd, '.build', 'output.pdf') try { statSync(pdfPath) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index ea54343..26b0270 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -66,6 +66,37 @@ function TerminalInstance({ id, cwd, cmd, args, visible }: { xterm.loadAddon(fitAddon) xterm.open(termRef.current) + // ── IME double-input workaround ───────────────────────────── + // + // Switching the input source mid-composition (e.g. CapsLock with a + // Chinese IME, committing the raw pinyin as ASCII) makes xterm send the + // text twice: its CompositionHelper.keydown() finalizes and sends + // synchronously, then the browser's compositionend fires and the helper + // sends the same textarea content again. Intercept in the capture phase + // on the container (runs before xterm's textarea listeners): when a + // non-IME keydown just force-finalized a composition, swallow the + // compositionend that follows so the text is only sent once. + const container = termRef.current + let imeComposing = false + let keydownFinalizedAt = 0 + const onCompStart = () => { imeComposing = true } + const onKeyDown = (e: KeyboardEvent) => { + if (imeComposing && e.keyCode !== 229 && e.keyCode !== 16 && e.keyCode !== 17 && e.keyCode !== 18) { + // xterm's CompositionHelper will finalize + send synchronously now + keydownFinalizedAt = Date.now() + imeComposing = false + } + } + const onCompEnd = (e: Event) => { + imeComposing = false + if (Date.now() - keydownFinalizedAt < 100) { + e.stopPropagation() // duplicate commit of already-sent text + } + } + container.addEventListener('compositionstart', onCompStart, true) + container.addEventListener('keydown', onKeyDown, true) + container.addEventListener('compositionend', onCompEnd, true) + setTimeout(() => fitAddon.fit(), 100) xtermRef.current = xterm @@ -101,6 +132,9 @@ function TerminalInstance({ id, cwd, cmd, args, visible }: { return () => { initializedRef.current = false resizeObserver.disconnect() + container.removeEventListener('compositionstart', onCompStart, true) + container.removeEventListener('keydown', onKeyDown, true) + container.removeEventListener('compositionend', onCompEnd, true) unsubData() unsubExit() window.api.ptyKill(id) -- cgit v1.2.3