summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/main/fileSyncBridge.ts8
-rw-r--r--src/main/index.ts41
-rw-r--r--src/mcp/lattex.mjs39
-rw-r--r--src/renderer/src/components/Terminal.tsx34
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 <project>/.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) // <project>/.lattex/.. = <project>
+ 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)