summaryrefslogtreecommitdiff
path: root/src/renderer
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 /src/renderer
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>
Diffstat (limited to 'src/renderer')
-rw-r--r--src/renderer/src/components/Terminal.tsx142
1 files changed, 88 insertions, 54 deletions
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}`)
}
}
}