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/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..032f2b2 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -1,10 +1,175 @@
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')
+// --- 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.
// The renderer must register roots via fs:set-root before fs operations work.
@@ -34,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)
@@ -94,6 +266,9 @@ class PtyManager {
}
}
+// --- Claude Scanners ---
+const claudeScanners = new Map()
+
// --- App ---
const ptyManager = new PtyManager()
let mainWindow = null
@@ -131,16 +306,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 +344,7 @@ ipcMain.on('pty:resize', (event, { id, cols, rows }) => {
ipcMain.handle('pty:kill', (event, { id }) => {
ptyManager.kill(id)
+ claudeScanners.delete(id)
return { success: true }
})
@@ -281,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
@@ -325,6 +523,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..84a42da 100644
--- a/src/preload/index.js
+++ b/src/preload/index.js
@@ -38,9 +38,36 @@ 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 }),
+ // 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..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 {
@@ -799,3 +803,424 @@ 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;
+}
+
+/* --- 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/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 (
${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('${inline(line)}
`) + i++ + } + + if (inList) out.push('$1')
+ // Links [text](url)
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
+}