Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/
dist/
out/
build/
release/
*.tsbuildinfo

# OS artifacts
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

</details>

<details>
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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"
}
Expand Down
36 changes: 33 additions & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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'))
Expand All @@ -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'))

Expand Down
31 changes: 31 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
68 changes: 62 additions & 6 deletions src/renderer/components/TabStrip.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string | null>(null)
const [editValue, setEditValue] = useState('')
const editRef = useRef<HTMLInputElement>(null)
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null)

useEffect(() => {
if (editingTabId && editRef.current) {
editRef.current.focus()
editRef.current.select()
}
}, [editingTabId])

return (
<div
Expand Down Expand Up @@ -76,7 +89,11 @@ export function TabStrip() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
onClick={() => 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',
Expand All @@ -88,9 +105,48 @@ export function TabStrip() {
fontWeight: isActive ? 500 : 400,
}}
>
{tab.pinned && (
<PushPin size={10} weight="fill" className="flex-shrink-0" style={{ color: colors.textTertiary }} />
)}
<StatusDot status={tab.status} hasUnread={tab.hasUnread} hasPermission={tab.permissionQueue.length > 0} />
<span className="truncate flex-1">{tab.title}</span>
{tabs.length > 1 && (
{editingTabId === tab.id ? (
<input
ref={editRef}
value={editValue}
onChange={(e) => 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%' }}
/>
) : (
<span
className="truncate flex-1"
onDoubleClick={(e) => {
e.stopPropagation()
if (clickTimer.current) { clearTimeout(clickTimer.current); clickTimer.current = null }
setEditingTabId(tab.id); setEditValue(tab.title)
}}
>
{tab.title}
</span>
)}
{tab.pinned ? (
<button
onClick={(e) => { e.stopPropagation(); togglePin(tab.id) }}
className="flex-shrink-0 rounded-full w-4 h-4 flex items-center justify-center transition-opacity"
style={{ opacity: 0, color: colors.textSecondary }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '0' }}
title="Unpin tab"
>
<PushPinSlash size={10} />
</button>
) : tabs.length > 1 ? (
<button
onClick={(e) => { e.stopPropagation(); closeTab(tab.id) }}
className="flex-shrink-0 rounded-full w-4 h-4 flex items-center justify-center transition-opacity"
Expand All @@ -103,7 +159,7 @@ export function TabStrip() {
>
<X size={10} />
</button>
)}
) : null}
</motion.div>
)
})}
Expand Down
105 changes: 105 additions & 0 deletions src/renderer/stores/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 []
}
}

Expand Down Expand Up @@ -151,6 +189,39 @@ export const useSessionStore = create<State>((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 {}
},

Expand Down Expand Up @@ -319,7 +390,37 @@ export const useSessionStore = create<State>((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()
Expand Down Expand Up @@ -617,6 +718,10 @@ export const useSessionStore = create<State>((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'
Expand Down
Loading