From 2c44aaa77c64e0575363388c9322eec315c2eed7 Mon Sep 17 00:00:00 2001 From: ParkerES Date: Sat, 21 Feb 2026 13:23:30 -0500 Subject: [PATCH 1/2] Add Claude integration, git status panel, command palette, and markdown preview Phase 2 implementation transforming the terminal wrapper into a Claude Code cockpit: Claude CLI detection via PTY side-channel scanner, git IPC handlers with allowlisted subcommands, searchable command palette (Ctrl+Shift+P), git status sidebar with stage/unstage, @codemirror/merge diff view, markdown preview for .md files, and auto-start Claude setting. Co-Authored-By: Claude Opus 4.6 --- src/main/claudeScanner.js | 87 ++++ src/main/gitHandlers.js | 123 ++++++ src/main/index.js | 23 ++ src/preload/index.js | 25 ++ src/renderer/src/App.css | 384 ++++++++++++++++++ src/renderer/src/App.jsx | 135 +++++- .../src/components/CommandPalette.jsx | 128 ++++++ src/renderer/src/components/DiffView.jsx | 96 +++++ src/renderer/src/components/EditorPanel.jsx | 146 ++++--- .../src/components/GitStatusPanel.jsx | 149 +++++++ src/renderer/src/components/PaneContainer.jsx | 6 +- src/renderer/src/components/Settings.jsx | 10 + src/renderer/src/components/Sidebar.jsx | 10 +- src/renderer/src/components/TerminalPane.jsx | 33 +- src/renderer/src/utils/languages.js | 43 ++ src/renderer/src/utils/markdownRender.js | 103 +++++ 16 files changed, 1405 insertions(+), 96 deletions(-) create mode 100644 src/main/claudeScanner.js create mode 100644 src/main/gitHandlers.js create mode 100644 src/renderer/src/components/CommandPalette.jsx create mode 100644 src/renderer/src/components/DiffView.jsx create mode 100644 src/renderer/src/components/GitStatusPanel.jsx create mode 100644 src/renderer/src/utils/languages.js create mode 100644 src/renderer/src/utils/markdownRender.js diff --git a/src/main/claudeScanner.js b/src/main/claudeScanner.js new file mode 100644 index 0000000..efffd51 --- /dev/null +++ b/src/main/claudeScanner.js @@ -0,0 +1,87 @@ +/** + * ClaudeScanner — side-channel scanner for PTY output. + * Detects Claude CLI session start/stop, model name, and cost output. + * Never blocks or modifies the PTY data stream. + */ +class ClaudeScanner { + constructor(ptyId, emitFn) { + this.ptyId = ptyId + this.emit = emitFn + this.buffer = '' + this.maxBuffer = 2048 + this.active = false + this.model = null + this.cost = null + } + + scan(data) { + this.buffer += data + if (this.buffer.length > this.maxBuffer) { + this.buffer = this.buffer.slice(-this.maxBuffer) + } + + // Detect Claude startup — look for common banner patterns + if (!this.active) { + // Claude Code banner: "╭" or "Claude Code" or model line like "claude-3-5-sonnet" + if ( + /Claude\s+(Code|[\d.]+)/i.test(this.buffer) || + /\u256D/.test(data) && /claude/i.test(this.buffer) + ) { + this.active = true + this.emit('claude:session-change', { ptyId: this.ptyId, active: true }) + this._checkModel() + } + } + + if (this.active) { + this._checkModel() + this._checkCost() + this._checkExit(data) + } + } + + _checkModel() { + // Match model patterns like "claude-opus-4-6", "claude-sonnet-4-6", etc. + const modelMatch = this.buffer.match(/(claude-[\w-]+[\d][\w-]*)/i) + if (modelMatch && modelMatch[1] !== this.model) { + this.model = modelMatch[1] + this.emit('claude:model-update', { ptyId: this.ptyId, model: this.model }) + } + } + + _checkCost() { + // Match cost patterns like "$0.05", "Total cost: $1.23" + const costMatch = this.buffer.match(/\$(\d+\.\d{2,})/g) + if (costMatch) { + const latest = costMatch[costMatch.length - 1] + if (latest !== this.cost) { + this.cost = latest + this.emit('claude:cost-update', { ptyId: this.ptyId, cost: this.cost }) + } + } + } + + _checkExit(data) { + // Detect Claude exit — shell prompt returns ($ or > at end of line after newline) + // This is heuristic: look for prompt-like patterns after a period of Claude being active + if (/\n[^\n]*[\$#>]\s*$/.test(data) && !/claude/i.test(data)) { + // Only trigger if recent buffer doesn't contain Claude-like output + const recentChunk = this.buffer.slice(-200) + if (!/[╭╰│─]/.test(recentChunk) && !/Claude/i.test(recentChunk.slice(-80))) { + this.active = false + this.model = null + this.cost = null + this.emit('claude:session-change', { ptyId: this.ptyId, active: false }) + } + } + } + + reset() { + this.buffer = '' + this.active = false + this.model = null + this.cost = null + } +} + +module.exports = { ClaudeScanner } diff --git a/src/main/gitHandlers.js b/src/main/gitHandlers.js new file mode 100644 index 0000000..8373a42 --- /dev/null +++ b/src/main/gitHandlers.js @@ -0,0 +1,123 @@ +const { execFile } = require('child_process') +const path = require('path') + +// Allowlisted git subcommands — only these can be executed +const ALLOWED_SUBCOMMANDS = new Set([ + 'status', 'diff', 'show', 'add', 'restore', 'rev-parse' +]) + +function runGit(args, cwd, timeout = 10000) { + return new Promise((resolve, reject) => { + const subcommand = args[0] + if (!ALLOWED_SUBCOMMANDS.has(subcommand)) { + return reject(new Error(`Git subcommand not allowed: ${subcommand}`)) + } + + execFile('git', args, { cwd, timeout, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => { + if (err) { + reject(new Error(stderr || err.message)) + } else { + resolve(stdout) + } + }) + }) +} + +function parseStatusLine(line) { + if (line.length < 4) return null + const x = line[0] // staged status + const y = line[1] // unstaged status + const filePath = line.slice(3) + + let status = 'modified' + if (x === '?' && y === '?') status = 'untracked' + else if (x === 'A' || y === 'A') status = 'added' + else if (x === 'D' || y === 'D') status = 'deleted' + else if (x === 'R' || y === 'R') status = 'renamed' + + return { + path: filePath, + staged: x !== ' ' && x !== '?', + status, + x, + y + } +} + +function registerGitHandlers(ipcMain, isPathAllowed) { + // Check if cwd is inside a git repo + ipcMain.handle('git:is-repo', async (event, { cwd }) => { + try { + await runGit(['rev-parse', '--is-inside-work-tree'], cwd) + return { isRepo: true } + } catch { + return { isRepo: false } + } + }) + + // Get parsed git status + ipcMain.handle('git:status', async (event, { cwd }) => { + try { + const output = await runGit(['status', '--porcelain=v1'], cwd) + const files = output + .split('\n') + .filter(Boolean) + .map(parseStatusLine) + .filter(Boolean) + return { files, error: null } + } catch (err) { + return { files: [], error: err.message } + } + }) + + // Get raw diff output for a file + ipcMain.handle('git:diff', async (event, { cwd, filePath, staged }) => { + try { + const args = staged + ? ['diff', '--cached', '--', filePath] + : ['diff', '--', filePath] + const output = await runGit(args, cwd) + return { diff: output, error: null } + } catch (err) { + return { diff: '', error: err.message } + } + }) + + // Get original file content from HEAD for MergeView + ipcMain.handle('git:diff-file', async (event, { cwd, filePath }) => { + try { + const output = await runGit(['show', `HEAD:${filePath}`], cwd) + return { content: output, error: null } + } catch (err) { + return { content: '', error: err.message } + } + }) + + // Stage a file + ipcMain.handle('git:stage', async (event, { cwd, filePath }) => { + if (!isPathAllowed(path.resolve(cwd, filePath))) { + return { error: 'Access denied: path outside allowed roots' } + } + try { + await runGit(['add', '--', filePath], cwd) + return { error: null } + } catch (err) { + return { error: err.message } + } + }) + + // Unstage a file + ipcMain.handle('git:unstage', async (event, { cwd, filePath }) => { + if (!isPathAllowed(path.resolve(cwd, filePath))) { + return { error: 'Access denied: path outside allowed roots' } + } + try { + await runGit(['restore', '--staged', '--', filePath], cwd) + return { error: null } + } catch (err) { + return { error: err.message } + } + }) +} + +module.exports = { registerGitHandlers } diff --git a/src/main/index.js b/src/main/index.js index 4380e47..4a3af73 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -4,6 +4,8 @@ const fs = require('fs') const os = require('os') const pty = require('node-pty') +const { registerGitHandlers } = require('./gitHandlers') +const { ClaudeScanner } = require('./claudeScanner') // --- Path Validation --- // Allowlist of root paths the renderer is permitted to access. @@ -94,6 +96,9 @@ class PtyManager { } } +// --- Claude Scanners --- +const claudeScanners = new Map() + // --- App --- const ptyManager = new PtyManager() let mainWindow = null @@ -131,16 +136,30 @@ function createWindow() { ipcMain.handle('pty:create', (event, { id, shell, cwd, args, cols, rows }) => { // Kill existing PTY with same ID if it exists (StrictMode remount) ptyManager.kill(id) + claudeScanners.delete(id) + const ptyProcess = ptyManager.create(id, { shell, cwd, args, cols, rows }) + + // Create Claude scanner for this PTY + const scanner = new ClaudeScanner(id, (channel, payload) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, payload) + } + }) + claudeScanners.set(id, scanner) + ptyProcess.onData((data) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('pty:data', { id, data }) } + // Side-channel scan — never blocks pty:data + scanner.scan(data) }) ptyProcess.onExit(({ exitCode }) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('pty:exit', { id, exitCode }) } + claudeScanners.delete(id) }) return { success: true } }) @@ -155,6 +174,7 @@ ipcMain.on('pty:resize', (event, { id, cols, rows }) => { ipcMain.handle('pty:kill', (event, { id }) => { ptyManager.kill(id) + claudeScanners.delete(id) return { success: true } }) @@ -325,6 +345,9 @@ ipcMain.on('window:maximize', () => { }) ipcMain.on('window:close', () => mainWindow?.close()) +// --- Git IPC Handlers --- +registerGitHandlers(ipcMain, isPathAllowed) + app.whenReady().then(createWindow) app.on('window-all-closed', async () => { diff --git a/src/preload/index.js b/src/preload/index.js index 7e39189..2e3273e 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -41,6 +41,31 @@ contextBridge.exposeInMainWorld('electronAPI', { readClipboardImage: () => ipcRenderer.invoke('clipboard:read-image'), saveTempImage: (dataURL) => ipcRenderer.invoke('app:save-temp-image', { dataURL }), + // Git + gitIsRepo: (cwd) => ipcRenderer.invoke('git:is-repo', { cwd }), + gitStatus: (cwd) => ipcRenderer.invoke('git:status', { cwd }), + gitDiff: (cwd, filePath, staged) => ipcRenderer.invoke('git:diff', { cwd, filePath, staged }), + gitDiffFile: (cwd, filePath) => ipcRenderer.invoke('git:diff-file', { cwd, filePath }), + gitStage: (cwd, filePath) => ipcRenderer.invoke('git:stage', { cwd, filePath }), + gitUnstage: (cwd, filePath) => ipcRenderer.invoke('git:unstage', { cwd, filePath }), + + // Claude Events + onClaudeSessionChange: (callback) => { + const handler = (_event, data) => callback(data) + ipcRenderer.on('claude:session-change', handler) + return () => ipcRenderer.removeListener('claude:session-change', handler) + }, + onClaudeCostUpdate: (callback) => { + const handler = (_event, data) => callback(data) + ipcRenderer.on('claude:cost-update', handler) + return () => ipcRenderer.removeListener('claude:cost-update', handler) + }, + onClaudeModelUpdate: (callback) => { + const handler = (_event, data) => callback(data) + ipcRenderer.on('claude:model-update', handler) + return () => ipcRenderer.removeListener('claude:model-update', handler) + }, + // Window Controls windowMinimize: () => ipcRenderer.send('window:minimize'), windowMaximize: () => ipcRenderer.send('window:maximize'), diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index 6eaf0f8..ed137d0 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -799,3 +799,387 @@ html, body, #root { .error-boundary__btn:hover { opacity: 0.85; } + +/* --- Claude State Indicators --- */ +.terminal-pane__indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-green); + flex-shrink: 0; + animation: pulse-glow 2s ease-in-out infinite; +} + +@keyframes pulse-glow { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.terminal-pane__model { + font-size: 10px; + color: var(--accent-cyan); + background: var(--bg-highlight); + padding: 1px 6px; + border-radius: 3px; + font-family: var(--font-mono); +} + +.terminal-pane__cost { + font-size: 10px; + color: var(--accent-yellow); + font-family: var(--font-mono); +} + +/* --- Git Status Panel --- */ +.git-panel { + display: flex; + flex-direction: column; + gap: 2px; +} + +.git-panel__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; +} + +.git-panel__count { + font-size: 11px; + color: var(--fg-dim); +} + +.git-panel__refresh { + background: transparent; + border: none; + color: var(--fg-dim); + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + border-radius: 3px; +} + +.git-panel__refresh:hover { + background: var(--bg-highlight); + color: var(--fg); +} + +.git-panel__group-title { + font-size: 10px; + font-weight: 600; + color: var(--fg-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 6px 8px 2px; +} + +.git-panel__file { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-family: var(--font-mono); +} + +.git-panel__file:hover { + background: var(--bg-highlight); +} + +.git-panel__status { + font-weight: 700; + width: 14px; + text-align: center; + flex-shrink: 0; +} + +.git-panel__path { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg); +} + +.git-panel__action { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 3px; + background: transparent; + border: none; + color: var(--fg-dim); + cursor: pointer; + font-size: 14px; + opacity: 0; + transition: opacity 0.15s; + flex-shrink: 0; +} + +.git-panel__file:hover .git-panel__action { + opacity: 1; +} + +.git-panel__action:hover { + background: var(--bg-hover); + color: var(--fg); +} + +.git-panel__empty { + color: var(--fg-dim); + font-size: 12px; + padding: 8px; + text-align: center; +} + +/* --- Diff View --- */ +.diff-view { + height: 100%; + overflow: auto; +} + +.diff-view .cm-editor { + height: 100%; + font-family: var(--font-mono); + font-size: 13px; +} + +.diff-view .cm-mergeView { + height: 100%; +} + +.diff-view__loading, +.diff-view__error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--fg-dim); + font-size: 13px; +} + +.diff-view__error { + color: var(--accent-red); +} + +/* --- Command Palette --- */ +.command-palette__overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + padding-top: 80px; + z-index: 1000; +} + +.command-palette { + width: 520px; + max-height: 400px; + background: var(--bg-medium); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + overflow: hidden; + align-self: flex-start; +} + +.command-palette__input { + width: 100%; + background: var(--bg-light); + border: none; + border-bottom: 1px solid var(--border); + color: var(--fg-bright); + padding: 12px 16px; + font-size: 14px; + font-family: var(--font-mono); + outline: none; +} + +.command-palette__input::placeholder { + color: var(--fg-dim); +} + +.command-palette__list { + flex: 1; + overflow-y: auto; + padding: 4px; +} + +.command-palette__item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + text-align: left; + color: var(--fg); + font-family: var(--font-ui); + font-size: 13px; +} + +.command-palette__item:hover, +.command-palette__item--selected { + background: var(--bg-highlight); +} + +.command-palette__item-name { + color: var(--fg-bright); + font-family: var(--font-mono); + font-size: 12px; + min-width: 120px; +} + +.command-palette__item-desc { + flex: 1; + color: var(--fg-dim); + font-size: 12px; +} + +.command-palette__item-badge { + font-size: 9px; + padding: 1px 5px; + border-radius: 3px; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.3px; +} + +.command-palette__item-badge--slash { + background: var(--bg-hover); + color: var(--accent-cyan); +} + +.command-palette__item-badge--model { + background: var(--bg-hover); + color: var(--accent-magenta); +} + +.command-palette__item-badge--action { + background: var(--bg-hover); + color: var(--accent-green); +} + +.command-palette__empty { + color: var(--fg-dim); + font-size: 13px; + padding: 16px; + text-align: center; +} + +/* --- Markdown Preview --- */ +.markdown-preview { + height: 100%; + overflow-y: auto; + padding: 24px 32px; + background: var(--bg-dark); + color: var(--fg); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.7; + max-width: 800px; +} + +.markdown-preview h1 { + font-size: 24px; + font-weight: 700; + color: var(--fg-bright); + margin: 24px 0 12px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); +} + +.markdown-preview h2 { + font-size: 20px; + font-weight: 600; + color: var(--fg-bright); + margin: 20px 0 10px; +} + +.markdown-preview h3 { + font-size: 16px; + font-weight: 600; + color: var(--fg-bright); + margin: 16px 0 8px; +} + +.markdown-preview h4, .markdown-preview h5, .markdown-preview h6 { + font-size: 14px; + font-weight: 600; + color: var(--fg-bright); + margin: 12px 0 6px; +} + +.markdown-preview p { + margin: 8px 0; +} + +.markdown-preview strong { + color: var(--fg-bright); + font-weight: 600; +} + +.markdown-preview em { + font-style: italic; +} + +.markdown-preview code { + background: var(--bg-highlight); + padding: 2px 5px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 13px; + color: var(--accent-cyan); +} + +.markdown-preview pre { + background: var(--bg-medium); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 16px; + margin: 12px 0; + overflow-x: auto; +} + +.markdown-preview pre code { + background: transparent; + padding: 0; + color: var(--fg); +} + +.markdown-preview a { + color: var(--accent-blue); + text-decoration: none; +} + +.markdown-preview a:hover { + text-decoration: underline; +} + +.markdown-preview ul { + padding-left: 24px; + margin: 8px 0; +} + +.markdown-preview li { + margin: 4px 0; +} + +.markdown-preview hr { + border: none; + border-top: 1px solid var(--border); + margin: 16px 0; +} diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx index a273a06..5a259f4 100644 --- a/src/renderer/src/App.jsx +++ b/src/renderer/src/App.jsx @@ -5,6 +5,7 @@ import Sidebar from './components/Sidebar' import PaneContainer from './components/PaneContainer' import InputArea from './components/InputArea' import EditorPanel from './components/EditorPanel' +import CommandPalette from './components/CommandPalette' import ErrorBoundary from './components/ErrorBoundary' const TAB_COLORS = [ @@ -34,7 +35,8 @@ export default function App() { const [activeTabId, setActiveTabId] = useState(tabs[0].id) const [settings, setSettings] = useState({ sidebarPosition: 'left', - fontSize: 14 + fontSize: 14, + autoStartClaude: false }) const [activePaneId, setActivePaneId] = useState(null) const settingsLoadedRef = useRef(false) @@ -43,9 +45,44 @@ export default function App() { const [openFiles, setOpenFiles] = useState([]) const [activeFileId, setActiveFileId] = useState(null) const [editorVisible, setEditorVisible] = useState(false) + const [editorSplit, setEditorSplit] = useState(false) + const [secondaryFileId, setSecondaryFileId] = useState(null) + + // Claude state — per-pane tracking + const [claudeState, setClaudeState] = useState({}) + + // Command palette + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false) const activeTab = tabs.find(t => t.id === activeTabId) || tabs[0] + // --- Claude event listeners --- + useEffect(() => { + const unsubSession = window.electronAPI.onClaudeSessionChange(({ ptyId, active }) => { + setClaudeState(prev => ({ + ...prev, + [ptyId]: { ...prev[ptyId], active } + })) + }) + const unsubModel = window.electronAPI.onClaudeModelUpdate(({ ptyId, model }) => { + setClaudeState(prev => ({ + ...prev, + [ptyId]: { ...prev[ptyId], model } + })) + }) + const unsubCost = window.electronAPI.onClaudeCostUpdate(({ ptyId, cost }) => { + setClaudeState(prev => ({ + ...prev, + [ptyId]: { ...prev[ptyId], cost } + })) + }) + return () => { + unsubSession() + unsubModel() + unsubCost() + } + }, []) + const handleAddTab = useCallback(() => { setTabs(prev => { const tab = createTab(null, prev.length) @@ -102,8 +139,8 @@ export default function App() { const handleOpenFile = useCallback((entry) => { if (entry.type === 'directory') return - // Check if already open - const existing = openFiles.find(f => f.path === entry.path) + // Check if already open (same path and mode) + const existing = openFiles.find(f => f.path === entry.path && f.mode === (entry.mode || undefined)) if (existing) { setActiveFileId(existing.id) setEditorVisible(true) @@ -111,12 +148,24 @@ export default function App() { } const fileId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` - const newFile = { id: fileId, path: entry.path, name: entry.name } + const newFile = { id: fileId, path: entry.path, name: entry.name, mode: entry.mode } setOpenFiles(prev => [...prev, newFile]) setActiveFileId(fileId) setEditorVisible(true) }, [openFiles]) + // Open a diff for a git file + const handleOpenDiff = useCallback((gitFile) => { + if (!activeTab.cwd) return + const fullPath = activeTab.cwd.replace(/\\/g, '/') + '/' + gitFile.path + handleOpenFile({ + path: fullPath, + name: gitFile.path.split('/').pop(), + type: 'file', + mode: 'diff' + }) + }, [activeTab.cwd, handleOpenFile]) + const handleCloseFile = useCallback((fileId) => { setOpenFiles(prev => { const next = prev.filter(f => f.id !== fileId) @@ -130,9 +179,13 @@ export default function App() { setEditorVisible(false) } } + if (fileId === secondaryFileId) { + setSecondaryFileId(null) + setEditorSplit(false) + } return next }) - }, [activeFileId]) + }, [activeFileId, secondaryFileId]) const handleSettingsChange = useCallback((key, value) => { setSettings(prev => ({ ...prev, [key]: value })) @@ -142,6 +195,26 @@ export default function App() { setEditorVisible(prev => !prev) }, []) + // Command palette action handler + const handlePaletteAction = useCallback((action) => { + switch (action) { + case 'splitH': handleSplitH(); break + case 'splitV': handleSplitV(); break + case 'toggleEditor': toggleEditor(); break + case 'addTab': handleAddTab(); break + case 'openClaudeMd': { + if (activeTab.cwd) { + handleOpenFile({ + path: activeTab.cwd.replace(/\\/g, '/') + '/CLAUDE.md', + name: 'CLAUDE.md', + type: 'file' + }) + } + break + } + } + }, [handleSplitH, handleSplitV, toggleEditor, handleAddTab, activeTab.cwd, handleOpenFile]) + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e) => { @@ -166,6 +239,11 @@ export default function App() { e.preventDefault() toggleEditor() } + // Ctrl+Shift+P: Command palette + if (e.ctrlKey && e.shiftKey && e.key === 'P') { + e.preventDefault() + setCommandPaletteOpen(prev => !prev) + } } window.addEventListener('keydown', handleKeyDown) @@ -191,6 +269,17 @@ export default function App() { return () => clearTimeout(timer) }, [settings]) + const paneContainerProps = { + panes: activeTab.panes, + onClosePane: handleClosePane, + cwd: activeTab.cwd, + onPaneActivate: setActivePaneId, + fontSize: settings.fontSize, + direction: activeTab.splitDirection, + autoStart: settings.autoStartClaude, + claudeState + } + return (
@@ -214,6 +303,7 @@ export default function App() { cwd={activeTab.cwd} onSendCommand={handleSendToTerminal} onOpenFile={handleOpenFile} + onOpenDiff={handleOpenDiff} settings={settings} onSettingsChange={handleSettingsChange} /> @@ -225,6 +315,13 @@ export default function App() { +
+ setCommandPaletteOpen(false)} + onSendToTerminal={handleSendToTerminal} + onAction={handlePaletteAction} + />
) diff --git a/src/renderer/src/components/CommandPalette.jsx b/src/renderer/src/components/CommandPalette.jsx new file mode 100644 index 0000000..82fab24 --- /dev/null +++ b/src/renderer/src/components/CommandPalette.jsx @@ -0,0 +1,128 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react' + +const COMMANDS = [ + // Claude slash commands + { id: 'slash-help', name: '/help', desc: 'Show help information', type: 'slash' }, + { id: 'slash-clear', name: '/clear', desc: 'Clear conversation history', type: 'slash' }, + { id: 'slash-compact', name: '/compact', desc: 'Compact conversation context', type: 'slash' }, + { id: 'slash-config', name: '/config', desc: 'View/modify configuration', type: 'slash' }, + { id: 'slash-cost', name: '/cost', desc: 'Show token usage and cost', type: 'slash' }, + { id: 'slash-doctor', name: '/doctor', desc: 'Check Claude Code health', type: 'slash' }, + { id: 'slash-init', name: '/init', desc: 'Initialize project with CLAUDE.md', type: 'slash' }, + { id: 'slash-login', name: '/login', desc: 'Switch Anthropic accounts', type: 'slash' }, + { id: 'slash-logout', name: '/logout', desc: 'Sign out from Anthropic', type: 'slash' }, + { id: 'slash-memory', name: '/memory', desc: 'Edit CLAUDE.md memory files', type: 'slash' }, + { id: 'slash-model', name: '/model', desc: 'Switch Claude model', type: 'slash' }, + { id: 'slash-permissions', name: '/permissions', desc: 'View/update permissions', type: 'slash' }, + { id: 'slash-review', name: '/review', desc: 'Request code review', type: 'slash' }, + { id: 'slash-status', name: '/status', desc: 'View account and session status', type: 'slash' }, + // Model switching + { id: 'model-opus', name: '/model claude-opus-4-6', desc: 'Switch to Opus 4.6', type: 'model' }, + { id: 'model-sonnet', name: '/model claude-sonnet-4-6', desc: 'Switch to Sonnet 4.6', type: 'model' }, + { id: 'model-haiku', name: '/model claude-haiku-4-5-20251001', desc: 'Switch to Haiku 4.5', type: 'model' }, + // App actions + { id: 'app-split-h', name: 'Split Horizontal', desc: 'Add a horizontal pane split', type: 'action', action: 'splitH' }, + { id: 'app-split-v', name: 'Split Vertical', desc: 'Add a vertical pane split', type: 'action', action: 'splitV' }, + { id: 'app-toggle-editor', name: 'Toggle Editor', desc: 'Show/hide the editor panel', type: 'action', action: 'toggleEditor' }, + { id: 'app-new-tab', name: 'New Tab', desc: 'Open a new project tab', type: 'action', action: 'addTab' }, + { id: 'app-claude-md', name: 'Open CLAUDE.md', desc: 'Open CLAUDE.md in editor', type: 'action', action: 'openClaudeMd' }, +] + +function fuzzyMatch(query, text) { + const q = query.toLowerCase() + const t = text.toLowerCase() + if (t.includes(q)) return true + let qi = 0 + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) qi++ + } + return qi === q.length +} + +export default function CommandPalette({ open, onClose, onSendToTerminal, onAction }) { + const [query, setQuery] = useState('') + const [selectedIdx, setSelectedIdx] = useState(0) + const inputRef = useRef(null) + + const filtered = query + ? COMMANDS.filter(cmd => fuzzyMatch(query, cmd.name) || fuzzyMatch(query, cmd.desc)) + : COMMANDS + + // Focus input when opened + useEffect(() => { + if (open) { + setQuery('') + setSelectedIdx(0) + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [open]) + + // Reset selection when filter changes + useEffect(() => { + setSelectedIdx(0) + }, [query]) + + const execute = useCallback((cmd) => { + if (cmd.type === 'slash' || cmd.type === 'model') { + onSendToTerminal?.(cmd.name) + } else if (cmd.type === 'action') { + onAction?.(cmd.action) + } + onClose() + }, [onSendToTerminal, onAction, onClose]) + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } else if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIdx(prev => Math.min(prev + 1, filtered.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIdx(prev => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (filtered[selectedIdx]) { + execute(filtered[selectedIdx]) + } + } + }, [onClose, filtered, selectedIdx, execute]) + + if (!open) return null + + return ( +
+
e.stopPropagation()}> + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type a command..." + /> +
+ {filtered.map((cmd, idx) => ( + + ))} + {filtered.length === 0 && ( +
No matching commands
+ )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/DiffView.jsx b/src/renderer/src/components/DiffView.jsx new file mode 100644 index 0000000..24d0662 --- /dev/null +++ b/src/renderer/src/components/DiffView.jsx @@ -0,0 +1,96 @@ +import React, { useEffect, useRef, useState } from 'react' +import { MergeView } from '@codemirror/merge' +import { EditorView } from '@codemirror/view' +import { EditorState } from '@codemirror/state' +import { tokyoNightStorm } from '@uiw/codemirror-theme-tokyo-night-storm' +import { getLanguageExtension } from '../utils/languages' + +export default function DiffView({ filePath, cwd }) { + const containerRef = useRef(null) + const viewRef = useRef(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!containerRef.current || !filePath || !cwd) return + + let destroyed = false + + async function loadDiff() { + setLoading(true) + setError(null) + try { + // Get the relative path for git commands + const relativePath = filePath.replace(/\\/g, '/') + const cwdNorm = cwd.replace(/\\/g, '/') + const relPath = relativePath.startsWith(cwdNorm) + ? relativePath.slice(cwdNorm.length + 1) + : relativePath + + const [originalResult, currentResult] = await Promise.all([ + window.electronAPI.gitDiffFile(cwd, relPath), + window.electronAPI.readFile(filePath) + ]) + + if (destroyed) return + + const original = originalResult.content || '' + const modified = currentResult.content || '' + + // Clean up previous view + if (viewRef.current) { + viewRef.current.destroy() + viewRef.current = null + } + + const langExt = getLanguageExtension(filePath) + const extensions = [ + tokyoNightStorm, + EditorView.editable.of(false), + ...(Array.isArray(langExt) ? langExt : [langExt]) + ] + + const view = new MergeView({ + a: { + doc: original, + extensions + }, + b: { + doc: modified, + extensions + }, + parent: containerRef.current, + collapseUnchanged: { margin: 3, minSize: 4 }, + gutter: true + }) + + viewRef.current = view + } catch (err) { + if (!destroyed) setError(err.message) + } + if (!destroyed) setLoading(false) + } + + loadDiff() + + return () => { + destroyed = true + if (viewRef.current) { + viewRef.current.destroy() + viewRef.current = null + } + } + }, [filePath, cwd]) + + if (loading) { + return
Loading diff...
+ } + + if (error) { + return
Error: {error}
+ } + + return ( +
+ ) +} diff --git a/src/renderer/src/components/EditorPanel.jsx b/src/renderer/src/components/EditorPanel.jsx index 99d5d32..3e92141 100644 --- a/src/renderer/src/components/EditorPanel.jsx +++ b/src/renderer/src/components/EditorPanel.jsx @@ -1,54 +1,24 @@ import React, { useState, useEffect, useCallback, useRef } from 'react' import CodeMirror from '@uiw/react-codemirror' import { tokyoNightStorm } from '@uiw/codemirror-theme-tokyo-night-storm' -import { javascript } from '@codemirror/lang-javascript' -import { python } from '@codemirror/lang-python' -import { json } from '@codemirror/lang-json' -import { markdown } from '@codemirror/lang-markdown' -import { html } from '@codemirror/lang-html' -import { css } from '@codemirror/lang-css' -import { cpp } from '@codemirror/lang-cpp' -import { java } from '@codemirror/lang-java' -import { rust } from '@codemirror/lang-rust' import { EditorView } from '@codemirror/view' - -const LANG_MAP = { - js: () => javascript({ jsx: true }), - jsx: () => javascript({ jsx: true }), - ts: () => javascript({ jsx: true, typescript: true }), - tsx: () => javascript({ jsx: true, typescript: true }), - mjs: () => javascript(), - cjs: () => javascript(), - py: () => python(), - json: () => json(), - md: () => markdown(), - markdown: () => markdown(), - html: () => html(), - htm: () => html(), - css: () => css(), - scss: () => css(), - c: () => cpp(), - cpp: () => cpp(), - h: () => cpp(), - hpp: () => cpp(), - java: () => java(), - rs: () => rust(), -} - -function getLanguageExtension(filePath) { - const ext = filePath.split('.').pop()?.toLowerCase() - const factory = LANG_MAP[ext] - return factory ? factory() : [] -} +import { Panel, Group, Separator } from 'react-resizable-panels' +import { getLanguageExtension, isMarkdownFile } from '../utils/languages' +import { renderMarkdown } from '../utils/markdownRender' +import DiffView from './DiffView' function getFileName(filePath) { return filePath.split(/[\\/]/).pop() } -export default function EditorPanel({ openFiles, activeFileId, onSelectFile, onCloseFile, onUpdateContent }) { +export default function EditorPanel({ + openFiles, activeFileId, onSelectFile, onCloseFile, onUpdateContent, + cwd, editorSplit, secondaryFileId, onSelectSecondaryFile +}) { const [fileContents, setFileContents] = useState({}) const [loadingFiles, setLoadingFiles] = useState({}) const [modifiedFiles, setModifiedFiles] = useState(new Set()) + const [previewVisible, setPreviewVisible] = useState(false) const debounceTimers = useRef({}) const loadedFilesRef = useRef(new Set()) @@ -121,6 +91,11 @@ export default function EditorPanel({ openFiles, activeFileId, onSelectFile, onC const activeFile = openFiles.find(f => f.id === activeFileId) const activeContent = activeFile ? (fileContents[activeFile.id] ?? '') : '' const isLoading = activeFile ? loadingFiles[activeFile.id] : false + const isDiffMode = activeFile?.mode === 'diff' + const isMd = activeFile && isMarkdownFile(activeFile.path) + + const secondaryFile = editorSplit ? openFiles.find(f => f.id === secondaryFileId) : null + const secondaryContent = secondaryFile ? (fileContents[secondaryFile.id] ?? '') : '' if (openFiles.length === 0) { return ( @@ -132,6 +107,41 @@ export default function EditorPanel({ openFiles, activeFileId, onSelectFile, onC ) } + const renderEditor = (file, content, isSecondary) => { + if (!file) return null + const fileLoading = loadingFiles[file.id] + if (fileLoading) return
Loading...
+ + if (file.mode === 'diff') { + return + } + + return ( + handleChange(file.id, file.path, value)} + basicSetup={{ + lineNumbers: true, + highlightActiveLineGutter: true, + highlightActiveLine: true, + foldGutter: true, + bracketMatching: true, + closeBrackets: true, + autocompletion: true, + indentOnInput: true, + searchKeymap: true, + }} + style={{ height: '100%', overflow: 'auto' }} + /> + ) + } + return (
@@ -143,6 +153,7 @@ export default function EditorPanel({ openFiles, activeFileId, onSelectFile, onC > {modifiedFiles.has(file.id) && } + {file.mode === 'diff' && D} {getFileName(file.path)} ))} +
+ {isMd && !isDiffMode && ( + + )}
{isLoading ? (
Loading...
+ ) : editorSplit && secondaryFile ? ( + + + {renderEditor(activeFile, activeContent, false)} + + + + {renderEditor(secondaryFile, secondaryContent, true)} + + + ) : isMd && previewVisible && !isDiffMode ? ( + + + {renderEditor(activeFile, activeContent, false)} + + + +
+ + ) : activeFile ? ( - handleChange(activeFile.id, activeFile.path, value)} - basicSetup={{ - lineNumbers: true, - highlightActiveLineGutter: true, - highlightActiveLine: true, - foldGutter: true, - bracketMatching: true, - closeBrackets: true, - autocompletion: true, - indentOnInput: true, - searchKeymap: true, - }} - style={{ height: '100%', overflow: 'auto' }} - /> + renderEditor(activeFile, activeContent, false) ) : null}
diff --git a/src/renderer/src/components/GitStatusPanel.jsx b/src/renderer/src/components/GitStatusPanel.jsx new file mode 100644 index 0000000..28d5263 --- /dev/null +++ b/src/renderer/src/components/GitStatusPanel.jsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react' + +const STATUS_LABELS = { + modified: 'M', + added: 'A', + deleted: 'D', + renamed: 'R', + untracked: '?' +} + +const STATUS_COLORS = { + modified: 'var(--accent-yellow)', + added: 'var(--accent-green)', + deleted: 'var(--accent-red)', + renamed: 'var(--accent-cyan)', + untracked: 'var(--fg-dim)' +} + +export default function GitStatusPanel({ cwd, onOpenDiff }) { + const [files, setFiles] = useState([]) + const [isRepo, setIsRepo] = useState(false) + const [loading, setLoading] = useState(false) + const debounceRef = useRef(null) + + const refresh = useCallback(async () => { + if (!cwd) return + setLoading(true) + try { + const { isRepo: repo } = await window.electronAPI.gitIsRepo(cwd) + setIsRepo(repo) + if (repo) { + const { files: statusFiles } = await window.electronAPI.gitStatus(cwd) + setFiles(statusFiles || []) + } else { + setFiles([]) + } + } catch { + setFiles([]) + } + setLoading(false) + }, [cwd]) + + // Refresh on mount and when cwd changes + useEffect(() => { + refresh() + }, [refresh]) + + // Debounced refresh on fs:changed events + useEffect(() => { + const unsub = window.electronAPI.onFsChanged(() => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(refresh, 2000) + }) + return () => { + unsub() + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [refresh]) + + const handleStage = useCallback(async (filePath) => { + if (!cwd) return + await window.electronAPI.gitStage(cwd, filePath) + refresh() + }, [cwd, refresh]) + + const handleUnstage = useCallback(async (filePath) => { + if (!cwd) return + await window.electronAPI.gitUnstage(cwd, filePath) + refresh() + }, [cwd, refresh]) + + const handleFileClick = useCallback((file) => { + onOpenDiff?.(file) + }, [onOpenDiff]) + + if (!cwd) { + return
No project open
+ } + + if (!isRepo) { + return
Not a git repository
+ } + + const staged = files.filter(f => f.staged) + const unstaged = files.filter(f => !f.staged) + + return ( +
+
+ {files.length} changed + +
+ + {staged.length > 0 && ( + <> +
Staged
+ {staged.map(file => ( +
handleFileClick(file)}> + + {STATUS_LABELS[file.status] || '?'} + + {file.path} + +
+ ))} + + )} + + {unstaged.length > 0 && ( + <> +
Changes
+ {unstaged.map(file => ( +
handleFileClick(file)}> + + {STATUS_LABELS[file.status] || '?'} + + {file.path} + +
+ ))} + + )} + + {files.length === 0 && !loading && ( +
Working tree clean
+ )} +
+ ) +} diff --git a/src/renderer/src/components/PaneContainer.jsx b/src/renderer/src/components/PaneContainer.jsx index 4157688..1710a83 100644 --- a/src/renderer/src/components/PaneContainer.jsx +++ b/src/renderer/src/components/PaneContainer.jsx @@ -2,7 +2,7 @@ import React from 'react' import { Panel, Group, Separator } from 'react-resizable-panels' import TerminalPane from './TerminalPane' -export default function PaneContainer({ panes, onClosePane, cwd, onPaneActivate, direction, fontSize }) { +export default function PaneContainer({ panes, onClosePane, cwd, onPaneActivate, direction, fontSize, autoStart, claudeState }) { if (panes.length === 0) { return (
onPaneActivate?.(panes[0].id)} fontSize={fontSize} + autoStart={autoStart} + claudeState={claudeState} /> ) } @@ -43,6 +45,8 @@ export default function PaneContainer({ panes, onClosePane, cwd, onPaneActivate, canClose={panes.length > 1} onActivate={() => onPaneActivate?.(pane.id)} fontSize={fontSize} + autoStart={autoStart} + claudeState={claudeState} /> diff --git a/src/renderer/src/components/Settings.jsx b/src/renderer/src/components/Settings.jsx index 8f702df..17ca554 100644 --- a/src/renderer/src/components/Settings.jsx +++ b/src/renderer/src/components/Settings.jsx @@ -35,6 +35,16 @@ export default function Settings({ settings, onChange }) {
+
+
Claude
+
+ Auto-start Claude +
+
About
diff --git a/src/renderer/src/components/Sidebar.jsx b/src/renderer/src/components/Sidebar.jsx index 1c9d820..844f402 100644 --- a/src/renderer/src/components/Sidebar.jsx +++ b/src/renderer/src/components/Sidebar.jsx @@ -1,15 +1,17 @@ import React, { useState } from 'react' import FileExplorer from './FileExplorer' import CommandList from './CommandList' +import GitStatusPanel from './GitStatusPanel' import Settings from './Settings' const SECTIONS = [ { id: 'explorer', icon: '\u{1F4C1}', label: 'Explorer' }, + { id: 'git', icon: '\u{1F500}', label: 'Git' }, { id: 'commands', icon: '\u26A1', label: 'Commands' }, { id: 'settings', icon: '\u2699', label: 'Settings' } ] -export default function Sidebar({ cwd, onSendCommand, onOpenFile, settings, onSettingsChange }) { +export default function Sidebar({ cwd, onSendCommand, onOpenFile, onOpenDiff, settings, onSettingsChange }) { const [activeSection, setActiveSection] = useState('explorer') return ( @@ -33,6 +35,12 @@ export default function Sidebar({ cwd, onSendCommand, onOpenFile, settings, onSe )} + {activeSection === 'git' && ( + <> +
Git Status
+ + + )} {activeSection === 'commands' && ( <>
Claude Commands
diff --git a/src/renderer/src/components/TerminalPane.jsx b/src/renderer/src/components/TerminalPane.jsx index 2bdab40..7e56277 100644 --- a/src/renderer/src/components/TerminalPane.jsx +++ b/src/renderer/src/components/TerminalPane.jsx @@ -1,11 +1,11 @@ import React, { useRef, useEffect } from 'react' import { useTerminal } from '../hooks/useTerminal' -export default function TerminalPane({ pane, onClose, cwd, canClose, onActivate, fontSize }) { +export default function TerminalPane({ pane, onClose, cwd, canClose, onActivate, fontSize, autoStart, claudeState }) { const containerRef = useRef(null) const { fit, focus } = useTerminal(pane.ptyId, containerRef, { cwd, - autoStart: false, + autoStart, fontSize }) @@ -19,19 +19,28 @@ export default function TerminalPane({ pane, onClose, cwd, canClose, onActivate, onActivate?.() } + const cs = claudeState?.[pane.ptyId] + return (
- {pane.ptyId.slice(0, 16)} - {canClose && ( - - )} +
+ {cs?.active && } + {pane.ptyId.slice(0, 16)} + {cs?.model && {cs.model}} +
+
+ {cs?.cost && {cs.cost}} + {canClose && ( + + )} +
diff --git a/src/renderer/src/utils/languages.js b/src/renderer/src/utils/languages.js new file mode 100644 index 0000000..97085c3 --- /dev/null +++ b/src/renderer/src/utils/languages.js @@ -0,0 +1,43 @@ +import { javascript } from '@codemirror/lang-javascript' +import { python } from '@codemirror/lang-python' +import { json } from '@codemirror/lang-json' +import { markdown } from '@codemirror/lang-markdown' +import { html } from '@codemirror/lang-html' +import { css } from '@codemirror/lang-css' +import { cpp } from '@codemirror/lang-cpp' +import { java } from '@codemirror/lang-java' +import { rust } from '@codemirror/lang-rust' + +const LANG_MAP = { + js: () => javascript({ jsx: true }), + jsx: () => javascript({ jsx: true }), + ts: () => javascript({ jsx: true, typescript: true }), + tsx: () => javascript({ jsx: true, typescript: true }), + mjs: () => javascript(), + cjs: () => javascript(), + py: () => python(), + json: () => json(), + md: () => markdown(), + markdown: () => markdown(), + html: () => html(), + htm: () => html(), + css: () => css(), + scss: () => css(), + c: () => cpp(), + cpp: () => cpp(), + h: () => cpp(), + hpp: () => cpp(), + java: () => java(), + rs: () => rust(), +} + +export function getLanguageExtension(filePath) { + const ext = filePath.split('.').pop()?.toLowerCase() + const factory = LANG_MAP[ext] + return factory ? factory() : [] +} + +export function isMarkdownFile(filePath) { + const ext = filePath.split('.').pop()?.toLowerCase() + return ext === 'md' || ext === 'markdown' +} diff --git a/src/renderer/src/utils/markdownRender.js b/src/renderer/src/utils/markdownRender.js new file mode 100644 index 0000000..62b8c32 --- /dev/null +++ b/src/renderer/src/utils/markdownRender.js @@ -0,0 +1,103 @@ +/** + * Lightweight markdown to HTML renderer. + * Supports: headings, bold, italic, inline code, code blocks, links, lists, paragraphs, horizontal rules. + * Zero dependencies, ~60 lines. + */ + +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +export function renderMarkdown(src) { + const lines = src.split('\n') + const out = [] + let i = 0 + let inList = false + + while (i < lines.length) { + const line = lines[i] + + // Code block (fenced) + if (line.startsWith('```')) { + const lang = line.slice(3).trim() + const codeLines = [] + i++ + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(escapeHtml(lines[i])) + i++ + } + i++ // skip closing ``` + out.push(`
${codeLines.join('\n')}
`) + continue + } + + // Close list if we're not on a list item + if (inList && !/^\s*[-*+]\s/.test(line) && !/^\s*\d+\.\s/.test(line)) { + out.push('') + inList = false + } + + // Horizontal rule + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + out.push('
') + i++ + continue + } + + // Heading + const headingMatch = line.match(/^(#{1,6})\s+(.+)/) + if (headingMatch) { + const level = headingMatch[1].length + out.push(`${inline(headingMatch[2])}`) + i++ + continue + } + + // Unordered list item + if (/^\s*[-*+]\s/.test(line)) { + if (!inList) { out.push('
    '); inList = true } + out.push(`
  • ${inline(line.replace(/^\s*[-*+]\s/, ''))}
  • `) + i++ + continue + } + + // Ordered list item + if (/^\s*\d+\.\s/.test(line)) { + if (!inList) { out.push('
      '); inList = true } + out.push(`
    • ${inline(line.replace(/^\s*\d+\.\s/, ''))}
    • `) + i++ + continue + } + + // Empty line + if (line.trim() === '') { + i++ + continue + } + + // Paragraph + out.push(`

      ${inline(line)}

      `) + i++ + } + + if (inList) out.push('
    ') + return out.join('\n') +} + +function inline(text) { + return escapeHtml(text) + // Bold **text** or __text__ + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + // Italic *text* or _text_ + .replace(/\*(.+?)\*/g, '$1') + .replace(/_(.+?)_/g, '$1') + // Inline code `code` + .replace(/`([^`]+)`/g, '$1') + // Links [text](url) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') +} From 46fb35734666646ce361672ceeb212e8cc342c87 Mon Sep 17 00:00:00 2001 From: ParkerES Date: Sat, 21 Feb 2026 15:38:44 -0500 Subject: [PATCH 2/2] Add file icons, context menu, clipboard support, and fix nested Claude sessions - Replace emoji file icons with atom-file-icons for extension-specific colored icons - Add right-click context menu to terminal (copy, paste, select all, clear) - Add Ctrl+C/Ctrl+V clipboard integration in terminal via custom key handler - Strip CLAUDE_CODE_ENTRYPOINT and related env vars from PTY to prevent nested session detection when running claude inside the terminal - Inline git handlers and Claude scanner into main process bundle - Add clipboard read/write IPC handlers Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 7 + package.json | 1 + src/main/index.js | 184 ++++++++++++++++++- src/preload/index.js | 2 + src/renderer/src/App.css | 43 ++++- src/renderer/src/components/ContextMenu.jsx | 51 +++++ src/renderer/src/components/FileExplorer.jsx | 9 +- src/renderer/src/components/TerminalPane.jsx | 55 +++++- src/renderer/src/hooks/useTerminal.js | 27 +++ src/renderer/src/main.jsx | 1 + 10 files changed, 369 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/ContextMenu.jsx diff --git a/package-lock.json b/package-lock.json index a8b67db..2c8ed8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "atom-file-icons": "^1.0.3", "node-pty": "^1.1.0", "react": "^19.0.0", "react-arborist": "^3.4.3", @@ -2093,6 +2094,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/atom-file-icons": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/atom-file-icons/-/atom-file-icons-1.0.3.tgz", + "integrity": "sha512-qUWaK3A3Qqv3yC7sjYJfr9cyFDWyPsQXFXfRfeup20MtlxHGcI+VrZwqDJgc8+bF2h0Tzb3w0rG5HtoPwfFSvw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index 132336b..1c5c1fb 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "atom-file-icons": "^1.0.3", "node-pty": "^1.1.0", "react": "^19.0.0", "react-arborist": "^3.4.3", diff --git a/src/main/index.js b/src/main/index.js index 4a3af73..032f2b2 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,11 +1,174 @@ const { app, BrowserWindow, ipcMain, clipboard } = require('electron') +const { execFile } = require('child_process') const path = require('path') const fs = require('fs') const os = require('os') const pty = require('node-pty') -const { registerGitHandlers } = require('./gitHandlers') -const { ClaudeScanner } = require('./claudeScanner') + +// --- Claude Scanner --- +// Side-channel scanner for PTY output. Detects Claude CLI session start/stop, model name, and cost. +// Never blocks or modifies the PTY data stream. +class ClaudeScanner { + constructor(ptyId, emitFn) { + this.ptyId = ptyId + this.emit = emitFn + this.buffer = '' + this.maxBuffer = 2048 + this.active = false + this.model = null + this.cost = null + } + + scan(data) { + this.buffer += data + if (this.buffer.length > this.maxBuffer) { + this.buffer = this.buffer.slice(-this.maxBuffer) + } + + if (!this.active) { + if ( + /Claude\s+(Code|[\d.]+)/i.test(this.buffer) || + /\u256D/.test(data) && /claude/i.test(this.buffer) + ) { + this.active = true + this.emit('claude:session-change', { ptyId: this.ptyId, active: true }) + this._checkModel() + } + } + + if (this.active) { + this._checkModel() + this._checkCost() + this._checkExit(data) + } + } + + _checkModel() { + const modelMatch = this.buffer.match(/(claude-[\w-]+[\d][\w-]*)/i) + if (modelMatch && modelMatch[1] !== this.model) { + this.model = modelMatch[1] + this.emit('claude:model-update', { ptyId: this.ptyId, model: this.model }) + } + } + + _checkCost() { + const costMatch = this.buffer.match(/\$(\d+\.\d{2,})/g) + if (costMatch) { + const latest = costMatch[costMatch.length - 1] + if (latest !== this.cost) { + this.cost = latest + this.emit('claude:cost-update', { ptyId: this.ptyId, cost: this.cost }) + } + } + } + + _checkExit(data) { + if (/\n[^\n]*[\$#>]\s*$/.test(data) && !/claude/i.test(data)) { + const recentChunk = this.buffer.slice(-200) + if (!/[\u256D\u2570\u2502\u2500]/.test(recentChunk) && !/Claude/i.test(recentChunk.slice(-80))) { + this.active = false + this.model = null + this.cost = null + this.emit('claude:session-change', { ptyId: this.ptyId, active: false }) + } + } + } +} + +// --- Git Handlers --- +const GIT_ALLOWED_SUBCOMMANDS = new Set([ + 'status', 'diff', 'show', 'add', 'restore', 'rev-parse' +]) + +function runGit(args, cwd, timeout = 10000) { + return new Promise((resolve, reject) => { + if (!GIT_ALLOWED_SUBCOMMANDS.has(args[0])) { + return reject(new Error(`Git subcommand not allowed: ${args[0]}`)) + } + execFile('git', args, { cwd, timeout, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => { + if (err) reject(new Error(stderr || err.message)) + else resolve(stdout) + }) + }) +} + +function parseStatusLine(line) { + if (line.length < 4) return null + const x = line[0] + const y = line[1] + const filePath = line.slice(3) + let status = 'modified' + if (x === '?' && y === '?') status = 'untracked' + else if (x === 'A' || y === 'A') status = 'added' + else if (x === 'D' || y === 'D') status = 'deleted' + else if (x === 'R' || y === 'R') status = 'renamed' + return { path: filePath, staged: x !== ' ' && x !== '?', status, x, y } +} + +function registerGitHandlers(ipcMain, isPathAllowed) { + ipcMain.handle('git:is-repo', async (event, { cwd }) => { + try { + await runGit(['rev-parse', '--is-inside-work-tree'], cwd) + return { isRepo: true } + } catch { + return { isRepo: false } + } + }) + + ipcMain.handle('git:status', async (event, { cwd }) => { + try { + const output = await runGit(['status', '--porcelain=v1'], cwd) + const files = output.split('\n').filter(Boolean).map(parseStatusLine).filter(Boolean) + return { files, error: null } + } catch (err) { + return { files: [], error: err.message } + } + }) + + ipcMain.handle('git:diff', async (event, { cwd, filePath, staged }) => { + try { + const args = staged ? ['diff', '--cached', '--', filePath] : ['diff', '--', filePath] + const output = await runGit(args, cwd) + return { diff: output, error: null } + } catch (err) { + return { diff: '', error: err.message } + } + }) + + ipcMain.handle('git:diff-file', async (event, { cwd, filePath }) => { + try { + const output = await runGit(['show', `HEAD:${filePath}`], cwd) + return { content: output, error: null } + } catch (err) { + return { content: '', error: err.message } + } + }) + + ipcMain.handle('git:stage', async (event, { cwd, filePath }) => { + if (!isPathAllowed(path.resolve(cwd, filePath))) { + return { error: 'Access denied: path outside allowed roots' } + } + try { + await runGit(['add', '--', filePath], cwd) + return { error: null } + } catch (err) { + return { error: err.message } + } + }) + + ipcMain.handle('git:unstage', async (event, { cwd, filePath }) => { + if (!isPathAllowed(path.resolve(cwd, filePath))) { + return { error: 'Access denied: path outside allowed roots' } + } + try { + await runGit(['restore', '--staged', '--', filePath], cwd) + return { error: null } + } catch (err) { + return { error: err.message } + } + }) +} // --- Path Validation --- // Allowlist of root paths the renderer is permitted to access. @@ -36,12 +199,19 @@ class PtyManager { const cwd = options.cwd || process.env.HOME || process.env.USERPROFILE const args = options.args || [] + // Strip Claude Code session markers so child shells don't trigger + // nested-session detection when the user runs `claude` inside the PTY + const env = { ...process.env, TERM: 'xterm-256color' } + delete env.CLAUDE_CODE_ENTRYPOINT + delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS + delete env.CLAUDECODE + const ptyProcess = pty.spawn(shell, args, { name: 'xterm-256color', cols: options.cols || 120, rows: options.rows || 30, cwd, - env: { ...process.env, TERM: 'xterm-256color' } + env }) this.ptys.set(id, ptyProcess) @@ -301,6 +471,14 @@ ipcMain.handle('app:get-home-path', () => { }) // Clipboard IPC Handlers +ipcMain.handle('clipboard:write-text', (event, { text }) => { + clipboard.writeText(text) +}) + +ipcMain.handle('clipboard:read-text', () => { + return clipboard.readText() +}) + ipcMain.handle('clipboard:read-image', () => { const img = clipboard.readImage() if (img.isEmpty()) return null diff --git a/src/preload/index.js b/src/preload/index.js index 2e3273e..84a42da 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -38,6 +38,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getHomePath: () => ipcRenderer.invoke('app:get-home-path'), // Clipboard + clipboardWriteText: (text) => ipcRenderer.invoke('clipboard:write-text', { text }), + clipboardReadText: () => ipcRenderer.invoke('clipboard:read-text'), readClipboardImage: () => ipcRenderer.invoke('clipboard:read-image'), saveTempImage: (dataURL) => ipcRenderer.invoke('app:save-temp-image', { dataURL }), diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index ed137d0..a225cc7 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -295,7 +295,11 @@ html, body, #root { flex-shrink: 0; width: 16px; text-align: center; - font-size: 12px; + font-size: 14px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; } .file-tree-node__name { @@ -1183,3 +1187,40 @@ html, body, #root { border-top: 1px solid var(--border); margin: 16px 0; } + +/* --- Context Menu --- */ +.context-menu { + position: fixed; + background: var(--bg-medium); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + z-index: 1000; + padding: 4px; + min-width: 160px; +} + +.context-menu__item { + display: block; + width: 100%; + padding: 6px 12px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--fg); + font-size: 13px; + font-family: var(--font-ui); + text-align: left; + cursor: pointer; +} + +.context-menu__item:hover { + background: var(--bg-hover); + color: var(--fg-bright); +} + +.context-menu__separator { + height: 1px; + background: var(--border); + margin: 4px 0; +} diff --git a/src/renderer/src/components/ContextMenu.jsx b/src/renderer/src/components/ContextMenu.jsx new file mode 100644 index 0000000..9d36819 --- /dev/null +++ b/src/renderer/src/components/ContextMenu.jsx @@ -0,0 +1,51 @@ +import React, { useEffect, useRef } from 'react' + +export default function ContextMenu({ x, y, visible, hasSelection, onCopy, onPaste, onSelectAll, onClear, onClose }) { + const menuRef = useRef(null) + + useEffect(() => { + if (!visible) return + + const handleClick = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) { + onClose() + } + } + const handleKey = (e) => { + if (e.key === 'Escape') onClose() + } + const handleBlur = () => onClose() + + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKey) + window.addEventListener('blur', handleBlur) + + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKey) + window.removeEventListener('blur', handleBlur) + } + }, [visible, onClose]) + + if (!visible) return null + + return ( +
    + {hasSelection && ( + + )} + + +
    + +
    + ) +} diff --git a/src/renderer/src/components/FileExplorer.jsx b/src/renderer/src/components/FileExplorer.jsx index 7c346cd..f2cb7f1 100644 --- a/src/renderer/src/components/FileExplorer.jsx +++ b/src/renderer/src/components/FileExplorer.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react' +import { getIconClass } from 'atom-file-icons' function FileNode({ entry, depth, onSelect, refreshKey }) { const [expanded, setExpanded] = useState(false) @@ -27,9 +28,9 @@ function FileNode({ entry, depth, onSelect, refreshKey }) { } }, [entry, expanded, loadChildren, onSelect]) - const icon = entry.type === 'directory' - ? (expanded ? '📂' : '📁') - : '📄' + const iconClass = entry.type === 'directory' + ? getIconClass(entry.name, { colorMode: 'dark', isDir: true }) + : getIconClass(entry.name, { colorMode: 'dark' }) return ( <> @@ -38,7 +39,7 @@ function FileNode({ entry, depth, onSelect, refreshKey }) { style={{ paddingLeft: `${8 + depth * 16}px` }} onClick={handleClick} > - {icon} + {entry.name}
    {expanded && children && children.map(child => ( diff --git a/src/renderer/src/components/TerminalPane.jsx b/src/renderer/src/components/TerminalPane.jsx index 7e56277..e33f451 100644 --- a/src/renderer/src/components/TerminalPane.jsx +++ b/src/renderer/src/components/TerminalPane.jsx @@ -1,14 +1,17 @@ -import React, { useRef, useEffect } from 'react' +import React, { useRef, useState, useEffect, useCallback } from 'react' import { useTerminal } from '../hooks/useTerminal' +import ContextMenu from './ContextMenu' export default function TerminalPane({ pane, onClose, cwd, canClose, onActivate, fontSize, autoStart, claudeState }) { const containerRef = useRef(null) - const { fit, focus } = useTerminal(pane.ptyId, containerRef, { + const { termRef, writeToPty, fit, focus } = useTerminal(pane.ptyId, containerRef, { cwd, autoStart, fontSize }) + const [ctxMenu, setCtxMenu] = useState({ visible: false, x: 0, y: 0, hasSelection: false }) + useEffect(() => { const timer = setTimeout(() => focus(), 200) return () => clearTimeout(timer) @@ -19,6 +22,41 @@ export default function TerminalPane({ pane, onClose, cwd, canClose, onActivate, onActivate?.() } + const handleContextMenu = useCallback((e) => { + e.preventDefault() + const term = termRef.current + const hasSelection = term ? term.hasSelection() : false + setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, hasSelection }) + }, [termRef]) + + const closeMenu = useCallback(() => { + setCtxMenu((prev) => ({ ...prev, visible: false })) + }, []) + + const handleCopy = useCallback(() => { + const sel = termRef.current?.getSelection() + if (sel) window.electronAPI.clipboardWriteText(sel) + termRef.current?.clearSelection() + closeMenu() + }, [termRef, closeMenu]) + + const handlePaste = useCallback(() => { + window.electronAPI.clipboardReadText().then((text) => { + if (text) writeToPty(text) + }) + closeMenu() + }, [writeToPty, closeMenu]) + + const handleSelectAll = useCallback(() => { + termRef.current?.selectAll() + closeMenu() + }, [termRef, closeMenu]) + + const handleClear = useCallback(() => { + termRef.current?.clear() + closeMenu() + }, [termRef, closeMenu]) + const cs = claudeState?.[pane.ptyId] return ( @@ -42,7 +80,18 @@ export default function TerminalPane({ pane, onClose, cwd, canClose, onActivate, )}
-
+
+
) } diff --git a/src/renderer/src/hooks/useTerminal.js b/src/renderer/src/hooks/useTerminal.js index 98b05df..386489f 100644 --- a/src/renderer/src/hooks/useTerminal.js +++ b/src/renderer/src/hooks/useTerminal.js @@ -69,6 +69,33 @@ export function useTerminal(ptyId, containerRef, { cwd, autoStart, fontSize } = term.open(containerRef.current) + // Clipboard: Ctrl+C copies selection (or sends SIGINT), Ctrl+V pastes + term.attachCustomKeyEventHandler((e) => { + if (e.type !== 'keydown') return true + const ctrlOrMeta = e.ctrlKey || e.metaKey + + if (ctrlOrMeta && e.key === 'c') { + const selection = term.getSelection() + if (selection) { + window.electronAPI.clipboardWriteText(selection) + term.clearSelection() + return false + } + return true // no selection → SIGINT + } + + if (ctrlOrMeta && e.key === 'v') { + window.electronAPI.clipboardReadText().then((text) => { + if (text) { + window.electronAPI.ptyWrite({ id: ptyId, data: text }) + } + }) + return false + } + + return true + }) + // Small delay to ensure container is laid out requestAnimationFrame(() => { try { diff --git a/src/renderer/src/main.jsx b/src/renderer/src/main.jsx index 6b5ad0f..ed39024 100644 --- a/src/renderer/src/main.jsx +++ b/src/renderer/src/main.jsx @@ -2,5 +2,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import './App.css' +import 'atom-file-icons/dist/index.css' ReactDOM.createRoot(document.getElementById('root')).render()