summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-13 17:28:27 -0500
committerhaoyuren <13851610112@163.com>2026-03-13 17:28:27 -0500
commite377dabf99595a6783fd962a8765d2214a635ac2 (patch)
treeb6f8654e13579552f31326d7f2780005b13cfa7e
parentc069e833b98253f31ef153317a6212cefde07c9a (diff)
Separate Terminal and Claude into independent pty instances
- Support multiple named pty instances via ID-based IPC channels - Terminal tab spawns a shell, Claude tab spawns `claude` CLI separately - Fix pty race condition: old instance's onExit callback could delete the replacement instance from the Map during React StrictMode re-mount - Guard against StrictMode double-initialization in TerminalInstance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--src/main/index.ts46
-rw-r--r--src/preload/index.ts22
-rw-r--r--src/renderer/src/components/Terminal.tsx142
3 files changed, 128 insertions, 82 deletions
diff --git a/src/main/index.ts b/src/main/index.ts
index 0d93b17..9be1a74 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -11,7 +11,7 @@ import { CompilationManager } from './compilationManager'
import { FileSyncBridge } from './fileSyncBridge'
let mainWindow: BrowserWindow | null = null
-let ptyInstance: pty.IPty | null = null
+const ptyInstances = new Map<string, pty.IPty>()
let overleafSock: OverleafSocket | null = null
let compilationManager: CompilationManager | null = null
let fileSyncBridge: FileSyncBridge | null = null
@@ -91,13 +91,16 @@ ipcMain.handle('synctex:editFromPdf', async (_e, pdfPath: string, page: number,
// ── Terminal / PTY ───────────────────────────────────────────────
-ipcMain.handle('pty:spawn', async (_e, cwd: string) => {
- if (ptyInstance) {
- ptyInstance.kill()
+ipcMain.handle('pty:spawn', async (_e, id: string, cwd: string, cmd?: string, args?: string[]) => {
+ const existing = ptyInstances.get(id)
+ if (existing) {
+ existing.kill()
+ ptyInstances.delete(id)
}
- const shellPath = process.env.SHELL || '/bin/zsh'
- ptyInstance = pty.spawn(shellPath, ['-l'], {
+ const shellPath = cmd || process.env.SHELL || '/bin/zsh'
+ const shellArgs = args || ['-l']
+ const instance = pty.spawn(shellPath, shellArgs, {
name: 'xterm-256color',
cols: 80,
rows: 24,
@@ -105,28 +108,37 @@ ipcMain.handle('pty:spawn', async (_e, cwd: string) => {
env: process.env as Record<string, string>
})
- ptyInstance.onData((data) => {
- sendToRenderer('pty:data', data)
+ ptyInstances.set(id, instance)
+
+ instance.onData((data) => {
+ sendToRenderer(`pty:data:${id}`, data)
})
- ptyInstance.onExit(() => {
- sendToRenderer('pty:exit')
+ instance.onExit(() => {
+ // Only delete if this is still the current instance (avoid race with re-spawn)
+ if (ptyInstances.get(id) === instance) {
+ sendToRenderer(`pty:exit:${id}`)
+ ptyInstances.delete(id)
+ }
})
})
-ipcMain.handle('pty:write', async (_e, data: string) => {
- ptyInstance?.write(data)
+ipcMain.handle('pty:write', async (_e, id: string, data: string) => {
+ ptyInstances.get(id)?.write(data)
})
-ipcMain.handle('pty:resize', async (_e, cols: number, rows: number) => {
+ipcMain.handle('pty:resize', async (_e, id: string, cols: number, rows: number) => {
try {
- ptyInstance?.resize(cols, rows)
+ ptyInstances.get(id)?.resize(cols, rows)
} catch { /* ignore resize errors */ }
})
-ipcMain.handle('pty:kill', async () => {
- ptyInstance?.kill()
- ptyInstance = null
+ipcMain.handle('pty:kill', async (_e, id: string) => {
+ const instance = ptyInstances.get(id)
+ if (instance) {
+ instance.kill()
+ ptyInstances.delete(id)
+ }
})
// ── Overleaf Web Session (for comments) ─────────────────────────
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 1bf97b3..aa16872 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -16,20 +16,20 @@ const api = {
return () => ipcRenderer.removeListener('latex:log', handler)
},
- // Terminal
- ptySpawn: (cwd: string) => ipcRenderer.invoke('pty:spawn', cwd),
- ptyWrite: (data: string) => ipcRenderer.invoke('pty:write', data),
- ptyResize: (cols: number, rows: number) => ipcRenderer.invoke('pty:resize', cols, rows),
- ptyKill: () => ipcRenderer.invoke('pty:kill'),
- onPtyData: (cb: (data: string) => void) => {
+ // Terminal (supports multiple named instances)
+ ptySpawn: (id: string, cwd: string, cmd?: string, args?: string[]) => ipcRenderer.invoke('pty:spawn', id, cwd, cmd, args),
+ ptyWrite: (id: string, data: string) => ipcRenderer.invoke('pty:write', id, data),
+ ptyResize: (id: string, cols: number, rows: number) => ipcRenderer.invoke('pty:resize', id, cols, rows),
+ ptyKill: (id: string) => ipcRenderer.invoke('pty:kill', id),
+ onPtyData: (id: string, cb: (data: string) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: string) => cb(data)
- ipcRenderer.on('pty:data', handler)
- return () => ipcRenderer.removeListener('pty:data', handler)
+ ipcRenderer.on(`pty:data:${id}`, handler)
+ return () => ipcRenderer.removeListener(`pty:data:${id}`, handler)
},
- onPtyExit: (cb: () => void) => {
+ onPtyExit: (id: string, cb: () => void) => {
const handler = () => cb()
- ipcRenderer.on('pty:exit', handler)
- return () => ipcRenderer.removeListener('pty:exit', handler)
+ ipcRenderer.on(`pty:exit:${id}`, handler)
+ return () => ipcRenderer.removeListener(`pty:exit:${id}`, handler)
},
// SyncTeX
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
index e2eb5e8..9a2e24c 100644
--- a/src/renderer/src/components/Terminal.tsx
+++ b/src/renderer/src/components/Terminal.tsx
@@ -1,44 +1,55 @@
// Copyright (c) 2026 Yuren Hao
// Licensed under AGPL-3.0 - see LICENSE file
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState, useCallback } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { useAppStore } from '../stores/appStore'
-export default function Terminal() {
+const XTERM_THEME = {
+ background: '#2D2A24',
+ foreground: '#E8DFC0',
+ cursor: '#FFF8E7',
+ selectionBackground: '#5C5040',
+ black: '#2D2A24',
+ red: '#C75643',
+ green: '#5B8A3C',
+ yellow: '#B8860B',
+ blue: '#4A6FA5',
+ magenta: '#8B6B8B',
+ cyan: '#5B8A8A',
+ white: '#E8DFC0',
+ brightBlack: '#6B5B3E',
+ brightRed: '#D46A58',
+ brightGreen: '#6FA050',
+ brightYellow: '#D4A020',
+ brightBlue: '#5E84B8',
+ brightMagenta: '#A080A0',
+ brightCyan: '#6FA0A0',
+ brightWhite: '#FFF8E7'
+}
+
+/** A single xterm + pty instance */
+function TerminalInstance({ id, cwd, cmd, args, visible }: {
+ id: string
+ cwd: string
+ cmd?: string
+ args?: string[]
+ visible: boolean
+}) {
const termRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<XTerm | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
- const [mode, setMode] = useState<'terminal' | 'claude'>('terminal')
+ const spawnedRef = useRef(false)
+ const initializedRef = useRef(false)
useEffect(() => {
- if (!termRef.current) return
+ if (!termRef.current || initializedRef.current) return
+ initializedRef.current = true
const xterm = new XTerm({
- theme: {
- background: '#2D2A24',
- foreground: '#E8DFC0',
- cursor: '#FFF8E7',
- selectionBackground: '#5C5040',
- black: '#2D2A24',
- red: '#C75643',
- green: '#5B8A3C',
- yellow: '#B8860B',
- blue: '#4A6FA5',
- magenta: '#8B6B8B',
- cyan: '#5B8A8A',
- white: '#E8DFC0',
- brightBlack: '#6B5B3E',
- brightRed: '#D46A58',
- brightGreen: '#6FA050',
- brightYellow: '#D4A020',
- brightBlue: '#5E84B8',
- brightMagenta: '#A080A0',
- brightCyan: '#6FA0A0',
- brightWhite: '#FFF8E7'
- },
+ theme: XTERM_THEME,
fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", monospace',
fontSize: 13,
cursorBlink: true,
@@ -49,58 +60,73 @@ export default function Terminal() {
xterm.loadAddon(fitAddon)
xterm.open(termRef.current)
- // Fit after a small delay to ensure container is sized
setTimeout(() => fitAddon.fit(), 100)
xtermRef.current = xterm
fitAddonRef.current = fitAddon
- // Spawn shell in project sync directory
- const syncDir = useAppStore.getState().syncDir || '/tmp'
- window.api.ptySpawn(syncDir)
+ // Spawn pty
+ window.api.ptySpawn(id, cwd, cmd, args)
+ spawnedRef.current = true
- // Pipe data
- const unsubData = window.api.onPtyData((data) => {
+ const unsubData = window.api.onPtyData(id, (data) => {
xterm.write(data)
})
- const unsubExit = window.api.onPtyExit(() => {
+ const unsubExit = window.api.onPtyExit(id, () => {
xterm.writeln('\r\n[Process exited]')
+ spawnedRef.current = false
})
- // Send input
xterm.onData((data) => {
- window.api.ptyWrite(data)
+ window.api.ptyWrite(id, data)
})
- // Handle resize
const resizeObserver = new ResizeObserver(() => {
try {
fitAddon.fit()
- window.api.ptyResize(xterm.cols, xterm.rows)
+ if (spawnedRef.current) {
+ window.api.ptyResize(id, xterm.cols, xterm.rows)
+ }
} catch { /* ignore */ }
})
resizeObserver.observe(termRef.current)
return () => {
+ initializedRef.current = false
resizeObserver.disconnect()
unsubData()
unsubExit()
- window.api.ptyKill()
+ window.api.ptyKill(id)
xterm.dispose()
}
- }, [])
+ }, [id, cwd, cmd])
- const launchClaude = () => {
- if (!xtermRef.current) return
- window.api.ptyWrite('claude\n')
- setMode('claude')
- }
+ // Re-fit when becoming visible
+ useEffect(() => {
+ if (visible && fitAddonRef.current) {
+ setTimeout(() => fitAddonRef.current?.fit(), 50)
+ }
+ }, [visible])
- const sendToClaude = (prompt: string) => {
- if (!xtermRef.current) return
- window.api.ptyWrite(prompt + '\n')
- }
+ return (
+ <div
+ ref={termRef}
+ className="terminal-content"
+ style={visible ? undefined : { display: 'none' }}
+ />
+ )
+}
+
+export default function Terminal() {
+ const [mode, setMode] = useState<'terminal' | 'claude'>('terminal')
+ const [claudeSpawned, setClaudeSpawned] = useState(false)
+ const syncDir = useAppStore((s) => s.syncDir) || '/tmp'
+
+ const launchClaude = useCallback(() => {
+ setClaudeSpawned(true)
+ setMode('claude')
+ }, [])
return (
<div className="terminal-panel">
@@ -118,23 +144,31 @@ export default function Terminal() {
Claude
</button>
<div className="pdf-toolbar-spacer" />
- <QuickActions onSend={sendToClaude} />
+ <QuickActions ptyId={claudeSpawned ? 'claude' : 'terminal'} />
</div>
- <div ref={termRef} className="terminal-content" />
+
+ <TerminalInstance id="terminal" cwd={syncDir} visible={mode === 'terminal'} />
+ {claudeSpawned && (
+ <TerminalInstance id="claude" cwd={syncDir} cmd="claude" args={[]} visible={mode === 'claude'} />
+ )}
</div>
)
}
-function QuickActions({ onSend }: { onSend: (cmd: string) => void }) {
+function QuickActions({ ptyId }: { ptyId: string }) {
const { activeTab, fileContents } = useAppStore()
+ const send = (prompt: string) => {
+ window.api.ptyWrite(ptyId, prompt + '\n')
+ }
+
const actions = [
{
label: 'Fix Errors',
action: () => {
const log = useAppStore.getState().compileLog
if (log) {
- onSend(`Fix these LaTeX compilation errors:\n${log.slice(-2000)}`)
+ send(`Fix these LaTeX compilation errors:\n${log.slice(-2000)}`)
}
}
},
@@ -142,7 +176,7 @@ function QuickActions({ onSend }: { onSend: (cmd: string) => void }) {
label: 'Review',
action: () => {
if (activeTab && fileContents[activeTab]) {
- onSend(`Review this LaTeX file for issues and improvements: ${activeTab}`)
+ send(`Review this LaTeX file for issues and improvements: ${activeTab}`)
}
}
},
@@ -150,7 +184,7 @@ function QuickActions({ onSend }: { onSend: (cmd: string) => void }) {
label: 'Explain',
action: () => {
if (activeTab) {
- onSend(`Explain the structure and content of: ${activeTab}`)
+ send(`Explain the structure and content of: ${activeTab}`)
}
}
}