diff --git a/.gitignore b/.gitignore index 0bcaf41e..1bf9e8b1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ dist/ out/ build/ +release/ *.tsbuildinfo # OS artifacts diff --git a/README.md b/README.md index 3116f52b..6a1bc774 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,26 @@ npm run build npx electron . ``` +### Build as macOS App + +Generate a standalone `Clui CC.app` that runs without a terminal: + +```bash +npm run dist +``` + +The app is created at `release/mac-arm64/Clui CC.app` (Apple Silicon) or `release/mac/Clui CC.app` (Intel). + +To install, drag it to `/Applications`: + +```bash +cp -R "release/mac-arm64/Clui CC.app" /Applications/ +``` + +Then open it from Spotlight, Launchpad, or the Applications folder like any native app. + +> **Note:** The app is not code-signed. On first launch macOS may block it — go to **System Settings → Privacy & Security** and click **Open Anyway**. +
diff --git a/package.json b/package.json index cb4281eb..e2f85728 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dev": "electron-vite dev", "build": "electron-vite build", "preview": "electron-vite preview", + "dist": "electron-vite build --mode production && electron-builder --mac --dir", "doctor": "bash scripts/doctor.sh", "postinstall": "electron-builder install-app-deps && bash scripts/patch-dev-icon.sh" }, @@ -30,6 +31,15 @@ "build": { "appId": "com.clui.app", "productName": "Clui CC", + "directories": { + "output": "release" + }, + "files": [ + "dist/main/**/*", + "dist/preload/**/*", + "dist/renderer/**/*", + "package.json" + ], "mac": { "icon": "resources/icon.icns" } diff --git a/src/main/index.ts b/src/main/index.ts index 9d177379..ccf6d5a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,8 @@ import { log as _log, LOG_FILE, flushLogs } from './logger' import { IPC } from '../shared/types' import type { RunOptions, NormalizedEvent, EnrichedError } from '../shared/types' +app.disableHardwareAcceleration() + const DEBUG_MODE = process.env.CLUI_DEBUG === '1' const SPACES_DEBUG = DEBUG_MODE || process.env.CLUI_SPACES_DEBUG === '1' @@ -864,6 +866,34 @@ app.whenReady().then(() => { createWindow() snapshotWindowState('after createWindow') + // Override default app menu to remove Cmd+W (Close Window) — renderer handles it as close-tab + Menu.setApplicationMenu(Menu.buildFromTemplate([ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }, + ])) + if (SPACES_DEBUG) { mainWindow?.on('show', () => snapshotWindowState('event window show')) mainWindow?.on('hide', () => snapshotWindowState('event window hide')) @@ -890,11 +920,11 @@ app.whenReady().then(() => { } - // Primary: Option+Space (2 keys, doesn't conflict with shell) + // Primary: Cmd+Space (replaces Spotlight) // Fallback: Cmd+Shift+K kept as secondary shortcut - const registered = globalShortcut.register('Alt+Space', () => toggleWindow('shortcut Alt+Space')) + const registered = globalShortcut.register('CommandOrControl+Space', () => toggleWindow('shortcut Cmd+Space')) if (!registered) { - log('Alt+Space shortcut registration failed — macOS input sources may claim it') + log('Cmd+Space shortcut registration failed — check that Spotlight shortcut is disabled') } globalShortcut.register('CommandOrControl+Shift+K', () => toggleWindow('shortcut Cmd/Ctrl+Shift+K')) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6cf443b3..101b53a6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -91,6 +91,37 @@ export default function App() { } }, []) + // ─── Cmd+1..9 tab switching ─── + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (!e.metaKey && !e.ctrlKey) return + if (e.key === 't' || e.key === 'T') { + e.preventDefault() + useSessionStore.getState().createTab() + return + } + + if (e.key === 'w' || e.key === 'W') { + e.preventDefault() + const { activeTabId, closeTab } = useSessionStore.getState() + closeTab(activeTabId) + return + } + + const digit = parseInt(e.key, 10) + if (digit < 1 || digit > 9 || isNaN(digit)) return + + e.preventDefault() + const { tabs, selectTab } = useSessionStore.getState() + const index = digit - 1 + if (index < tabs.length) { + selectTab(tabs[index].id) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + const isExpanded = useSessionStore((s) => s.isExpanded) const marketplaceOpen = useSessionStore((s) => s.marketplaceOpen) const isRunning = activeTabStatus === 'running' || activeTabStatus === 'connecting' diff --git a/src/renderer/components/TabStrip.tsx b/src/renderer/components/TabStrip.tsx index 9a74dbc0..9a04dc60 100644 --- a/src/renderer/components/TabStrip.tsx +++ b/src/renderer/components/TabStrip.tsx @@ -1,6 +1,6 @@ -import React from 'react' +import React, { useState, useRef, useEffect } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { Plus, X } from '@phosphor-icons/react' +import { Plus, X, PushPin, PushPinSlash } from '@phosphor-icons/react' import { useSessionStore } from '../stores/sessionStore' import { HistoryPicker } from './HistoryPicker' import { SettingsPopover } from './SettingsPopover' @@ -42,7 +42,20 @@ export function TabStrip() { const selectTab = useSessionStore((s) => s.selectTab) const createTab = useSessionStore((s) => s.createTab) const closeTab = useSessionStore((s) => s.closeTab) + const togglePin = useSessionStore((s) => s.togglePin) + const renameTab = useSessionStore((s) => s.renameTab) const colors = useColors() + const [editingTabId, setEditingTabId] = useState(null) + const [editValue, setEditValue] = useState('') + const editRef = useRef(null) + const clickTimer = useRef | null>(null) + + useEffect(() => { + if (editingTabId && editRef.current) { + editRef.current.focus() + editRef.current.select() + } + }, [editingTabId]) return (
selectTab(tab.id)} + onClick={() => { + if (clickTimer.current) { clearTimeout(clickTimer.current); clickTimer.current = null; return } + clickTimer.current = setTimeout(() => { clickTimer.current = null; selectTab(tab.id) }, 200) + }} + onContextMenu={(e) => { e.preventDefault(); togglePin(tab.id) }} className="group flex items-center gap-1.5 cursor-pointer select-none flex-shrink-0 max-w-[160px] transition-all duration-150" style={{ background: isActive ? colors.tabActive : 'transparent', @@ -88,9 +105,48 @@ export function TabStrip() { fontWeight: isActive ? 500 : 400, }} > + {tab.pinned && ( + + )} 0} /> - {tab.title} - {tabs.length > 1 && ( + {editingTabId === tab.id ? ( + setEditValue(e.target.value)} + onBlur={() => { renameTab(tab.id, editValue); setEditingTabId(null) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { renameTab(tab.id, editValue); setEditingTabId(null) } + if (e.key === 'Escape') setEditingTabId(null) + }} + onClick={(e) => e.stopPropagation()} + className="truncate flex-1 bg-transparent outline-none border-none" + style={{ fontSize: 12, color: 'inherit', fontWeight: 'inherit', padding: 0, margin: 0, width: '100%' }} + /> + ) : ( + { + e.stopPropagation() + if (clickTimer.current) { clearTimeout(clickTimer.current); clickTimer.current = null } + setEditingTabId(tab.id); setEditValue(tab.title) + }} + > + {tab.title} + + )} + {tab.pinned ? ( + + ) : tabs.length > 1 ? ( - )} + ) : null} ) })} diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts index 806e4e92..6423317b 100644 --- a/src/renderer/stores/sessionStore.ts +++ b/src/renderer/stores/sessionStore.ts @@ -67,6 +67,8 @@ interface State { addDirectory: (dir: string) => void removeDirectory: (dir: string) => void setBaseDirectory: (dir: string) => void + togglePin: (tabId: string) => void + renameTab: (tabId: string, title: string) => void addAttachments: (attachments: Attachment[]) => void removeAttachment: (attachmentId: string) => void clearAttachments: () => void @@ -116,6 +118,42 @@ function makeLocalTab(): TabState { workingDirectory: '~', hasChosenDirectory: false, additionalDirs: [], + pinned: false, + } +} + +// ─── Pinned tabs persistence ─── + +interface PinnedTabData { + claudeSessionId: string + title: string + workingDirectory: string + additionalDirs: string[] +} + +const PINNED_STORAGE_KEY = 'clui-pinned-tabs' + +function savePinnedTabs(tabs: TabState[]): void { + const pinned: PinnedTabData[] = tabs + .filter((t) => t.pinned && t.claudeSessionId) + .map((t) => ({ + claudeSessionId: t.claudeSessionId!, + title: t.title, + workingDirectory: t.workingDirectory, + additionalDirs: t.additionalDirs, + })) + try { + localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify(pinned)) + } catch {} +} + +function loadPinnedTabs(): PinnedTabData[] { + try { + const raw = localStorage.getItem(PINNED_STORAGE_KEY) + if (!raw) return [] + return JSON.parse(raw) as PinnedTabData[] + } catch { + return [] } } @@ -151,6 +189,39 @@ export const useSessionStore = create((set, get) => ({ homePath: result.homePath || '~', }, }) + + // Restore pinned tabs from previous session + const pinnedData = loadPinnedTabs() + for (const p of pinnedData) { + try { + const { tabId } = await window.clui.createTab() + const history = await window.clui.loadSession(p.claudeSessionId, p.workingDirectory).catch(() => []) + let msgCounter2 = 0 + const messages: import('../../shared/types').Message[] = history.map((m) => ({ + id: `pinned-${++msgCounter2}`, + role: m.role as import('../../shared/types').Message['role'], + content: m.content, + toolName: m.toolName, + toolStatus: m.toolName ? 'completed' as const : undefined, + timestamp: m.timestamp, + })) + + const tab: TabState = { + ...makeLocalTab(), + id: tabId, + claudeSessionId: p.claudeSessionId, + title: p.title, + workingDirectory: p.workingDirectory, + hasChosenDirectory: true, + additionalDirs: p.additionalDirs, + messages, + pinned: true, + } + set((s) => ({ + tabs: [tab, ...s.tabs], + })) + } catch {} + } } catch {} }, @@ -319,7 +390,37 @@ export const useSessionStore = create((set, get) => ({ }, 100) }, + togglePin: (tabId) => { + set((s) => { + const tabs = s.tabs.map((t) => + t.id === tabId ? { ...t, pinned: !t.pinned } : t + ) + // Sort: pinned tabs first, preserve relative order within each group + const pinned = tabs.filter((t) => t.pinned) + const unpinned = tabs.filter((t) => !t.pinned) + const sorted = [...pinned, ...unpinned] + savePinnedTabs(sorted) + return { tabs: sorted } + }) + }, + + renameTab: (tabId, title) => { + const trimmed = title.trim() + if (!trimmed) return + set((s) => { + const tabs = s.tabs.map((t) => + t.id === tabId ? { ...t, title: trimmed } : t + ) + savePinnedTabs(tabs) + return { tabs } + }) + }, + closeTab: (tabId) => { + // Prevent closing pinned tabs + const tab = get().tabs.find((t) => t.id === tabId) + if (tab?.pinned) return + window.clui.closeTab(tabId).catch(() => {}) const s = get() @@ -617,6 +718,10 @@ export const useSessionStore = create((set, get) => ({ updated.sessionMcpServers = event.mcpServers updated.sessionSkills = event.skills updated.sessionVersion = event.version + // Persist pinned tabs when session ID is assigned + if (updated.pinned) { + setTimeout(() => savePinnedTabs(get().tabs), 0) + } // Don't change status/activity for warmup inits — they're invisible if (!event.isWarmup) { updated.status = 'running' diff --git a/src/shared/types.ts b/src/shared/types.ts index 9745613e..7f4277d7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -170,6 +170,8 @@ export interface TabState { hasChosenDirectory: boolean /** Extra directories accessible via --add-dir (session-preserving) */ additionalDirs: string[] + /** Whether this tab is pinned (stays left, protected from close, persists across restarts) */ + pinned: boolean } export interface Message {