diff --git a/CHANGELOG.md b/CHANGELOG.md index 74533e3..2043815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.0.74 + +- Fix: session status dot stuck on purple for sessions with large responses (#116) + - `tail -n 50` on large JSONL files exceeded `execFile` maxBuffer (1MB) + - Reduced to 15 lines + raised maxBuffer to 5MB +- Fix: eliminate tab flash on startup (default tab now passed via URL hash) +- Style: project paths display `~/` instead of `/Users//` +- Style: shortcut display uses macOS symbols (`⌘⌃R` instead of `Cmd+Ctrl+R`) +- Style: needs-attention dot changed from orange `#FFA726` to warm red `#F06856` +- Style: working pulse animation slowed from 2s to 2.5s +- Style: normal mode banner only shown on first launch +- Feat: clicking shortcut in title bar opens Settings → Shortcuts tab +- Feat: project search supports `~/` prefix and full path matching (with highlight) + ## 1.0.73 - Feat: embedded terminal search (`Cmd+F`) diff --git a/package.json b/package.json index b2d39d2..9d84019 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.73", + "version": "1.0.74", "description": "Quick switcher for VS Code, Cursor, and Claude Code sessions", "repository": { "type": "git", diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index c25bacb..e86abde 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -891,7 +891,10 @@ export const detectActiveSessions = async (): Promise => { if (!pid || !sessionId) continue; // Verify process is still alive - try { process.kill(pid, 0); } catch { continue; } + try { process.kill(pid, 0); } catch { + console.log(`[detect-active] PID ${pid} (${sessionId}) not alive, skipping`); + continue; + } entrypoints.set(sessionId, entrypoint); @@ -924,17 +927,22 @@ export const detectActiveSessions = async (): Promise => { activeMap.set(sessionId, pid); } else if (cwd) { // sessionId not in history — find session by cwd + console.log(`[detect-active] PID ${pid} sessionId ${sessionId} not in history.jsonl, trying cwd match (${cwd})`); const cwdCandidates = allSessions.filter(s => s.project === cwd && !activeMap.has(s.sessionId)); if (cwdCandidates.length === 1) { activeMap.set(cwdCandidates[0].sessionId, pid); } else if (cwdCandidates.length > 1) { // Multiple same-cwd candidates — queue for cross-reference needsCrossRef.push({ pid, cwd, candidates: cwdCandidates }); + } else { + console.log(`[detect-active] PID ${pid} sessionId ${sessionId}: no cwd match found`); } + } else { + console.log(`[detect-active] PID ${pid} sessionId ${sessionId}: not in history and no cwd`); } } - } catch { - // skip malformed files + } catch (err) { + console.error(`[detect-active] Error processing session file ${file}:`, err); } } @@ -954,8 +962,8 @@ export const detectActiveSessions = async (): Promise => { if (!fs.existsSync(sessionsDir)) { await detectActiveSessionsLegacy(activeMap); } - } catch { - // ignore + } catch (err) { + console.error('[detect-active] Error in detectActiveSessions:', err); } cachedActiveMap = activeMap; diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index b3bd35f..f021ec9 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -4,6 +4,9 @@ type IpcCallback = (event: Electron.IpcRendererEvent, ...args: any[]) => void; interface IElectronAPI { // App actions + getHomeDir: () => Promise; + getBannerSeen: () => Promise; + setBannerSeen: () => void; invokeVSCode: (path: string, option: string) => void; hideApp: () => void; openFolderSelector: () => void; diff --git a/src/main.ts b/src/main.ts index f18a8e5..ac136ad 100644 --- a/src/main.ts +++ b/src/main.ts @@ -410,7 +410,7 @@ const createSettingsWindow = ( return settingsWindow; }; -const createSwitcherWindow = (): BrowserWindow => { +const createSwitcherWindow = (initialMode?: string): BrowserWindow => { // Create the browser window. const window = new BrowserWindow({ // maximizable: false, @@ -431,7 +431,9 @@ const createSwitcherWindow = (): BrowserWindow => { }); // and load the index.html of the app. - window.loadURL(SWITCHER_WINDOW_WEBPACK_ENTRY); + // Pass initial mode via hash so renderer can use it synchronously (no async IPC) + const hash = initialMode ? `#mode=${initialMode}` : ''; + window.loadURL(SWITCHER_WINDOW_WEBPACK_ENTRY + hash); // Open external links in default browser const { shell } = require('electron'); @@ -619,6 +621,10 @@ ipcMain.on('search-working-folder', (event, path: string) => { } }); +ipcMain.handle('get-home-dir', () => { + return require('os').homedir(); +}); + ipcMain.on('hide-app', (event) => { hideSwitcherWindow(); }); @@ -1094,7 +1100,8 @@ const trayToggleEvtHandler = async () => { } } - switcherWindow = createSwitcherWindow(); + const defaultSwitcherMode = ((await settings.get('default-switcher-mode')) as string) || 'projects'; + switcherWindow = createSwitcherWindow(defaultSwitcherMode); if (isDebug) { console.log('when ready'); } @@ -1980,6 +1987,10 @@ ipcMain.handle('get-session-statuses', async () => { // Scan active sessions that don't have status files yet + cleanup stale ones try { const { activeMap, vscodeSessions } = await detectActiveSessions(); + if (isDebug) { + console.log('[session-status] activeMap keys:', Array.from(activeMap.keys())); + console.log('[session-status] status files:', Object.keys(obj)); + } cleanupStaleStatuses(new Set(activeMap.keys())); const allSessions = readClaudeSessions(500); // hoisted out of loop // Merge VS Code sessions for status scanning @@ -1988,19 +1999,30 @@ ipcMain.handle('get-session-statuses', async () => { .filter(([sessionId]) => !obj[sessionId]) .map(([sessionId]) => { const session = allKnown.find((s: any) => s.sessionId === sessionId); + if (!session && isDebug) { + console.log(`[session-status] active session ${sessionId} not found in allKnown (${allKnown.length} sessions)`); + } return session ? { sessionId, project: session.project } : null; }) .filter(Boolean) as { sessionId: string; project: string }[]; if (sessionsWithoutStatus.length > 0) { + if (isDebug) { + console.log('[session-status] scanning', sessionsWithoutStatus.length, 'sessions without status:', sessionsWithoutStatus.map(s => s.sessionId)); + } const scanned = await scanInitialStatuses(sessionsWithoutStatus); + if (isDebug) { + console.log('[session-status] scan results:', Array.from(scanned.entries())); + } scanned.forEach((v, k) => { obj[k] = { status: v, timestamp: Math.floor(Date.now() / 1000) }; // Persist scanned status to file so fs.watch treats all statuses uniformly writeStatusFile(k, v as string); }); } - } catch {} + } catch (err) { + console.error('[session-status] Error during status scan:', err); + } return obj; }); @@ -2009,6 +2031,14 @@ ipcMain.handle('get-app-mode', async () => { return appMode; }); +ipcMain.handle('get-banner-seen', async () => { + return await settings.get('normal-mode-banner-seen'); +}); + +ipcMain.on('set-banner-seen', async () => { + await settings.set('normal-mode-banner-seen', true); +}); + ipcMain.on('set-app-mode', async (_event, mode: string) => { const newMode = mode === 'menubar' ? 'menubar' : 'normal'; await settings.set('app-mode', newMode); diff --git a/src/popup.tsx b/src/popup.tsx index 1be7497..825e16c 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -46,11 +46,15 @@ const PopupDefaultExample = ({ saveCallback, openCallback, switcherMode, + openToTab, + onOpenToTabConsumed, }: { workingFolderPath?: string; saveCallback?: (key: string, value: string) => void; openCallback?: any; switcherMode?: string; + openToTab?: 'general' | 'sessions' | 'shortcuts' | null; + onOpenToTabConsumed?: () => void; }) => { const [isOpen, setIsOpen] = useState(false); const [launchAtLogin, setLaunchAtLogin] = useState(false); @@ -142,6 +146,15 @@ const PopupDefaultExample = ({ } }, [isOpen]); + // Allow parent to open Settings on a specific tab + useEffect(() => { + if (openToTab) { + setSettingsTab(openToTab); + setIsOpen(true); + onOpenToTabConsumed?.(); + } + }, [openToTab]); + const handleLaunchAtLoginChange = (checked: boolean) => { setLaunchAtLogin(checked); window.electronAPI.setLoginItemSettings(checked); diff --git a/src/preload.ts b/src/preload.ts index 6e6a028..a3f7bfa 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,6 +4,9 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { + getHomeDir: () => ipcRenderer.invoke('get-home-dir'), + getBannerSeen: () => ipcRenderer.invoke('get-banner-seen'), + setBannerSeen: () => ipcRenderer.send('set-banner-seen'), invokeVSCode: (path: string, option: string) => ipcRenderer.send('invoke-vscode', path, option), diff --git a/src/session-status-hooks.ts b/src/session-status-hooks.ts index 796c281..d963bc8 100644 --- a/src/session-status-hooks.ts +++ b/src/session-status-hooks.ts @@ -259,7 +259,7 @@ export const watchStatusDir = ( /** * Scan active sessions' JSONL files to determine initial status * (for sessions started before CodeV or before hooks were installed). - * Reads last ~50 lines of each session's JSONL to check: + * Reads last ~15 lines of each session's JSONL to check: * - Pending AskUserQuestion tool use → needs-attention * - Last assistant message with stop_reason "end_turn" → idle * - Otherwise → working @@ -270,7 +270,11 @@ export const scanInitialStatuses = async ( const { execFile } = require('child_process'); const tailFile = (filePath: string): Promise => new Promise((resolve) => { - execFile('tail', ['-n', '50', filePath], { encoding: 'utf-8', timeout: 3000 }, (err: any, stdout: string) => { + // Use 15 lines (not 50) — large assistant messages can make 50 lines > 1MB, + // exceeding execFile's maxBuffer and silently failing. + // The last ~5-10 lines are usually system entries; assistant entry is typically within 15. + execFile('tail', ['-n', '15', filePath], { encoding: 'utf-8', timeout: 3000, maxBuffer: 5 * 1024 * 1024 }, (err: any, stdout: string) => { + if (err) console.error(`[session-status] tailFile error for ${path.basename(filePath)}:`, err.message || err); resolve(err ? '' : stdout); }); }); @@ -290,7 +294,7 @@ export const scanInitialStatuses = async ( const jsonlPath = path.join(claudeDir, encodedProject, `${session.sessionId}.jsonl`); if (!fs.existsSync(jsonlPath)) return; - // Read last 50 lines + // Read last 15 lines const tail = await tailFile(jsonlPath); if (!tail.trim()) return; @@ -359,10 +363,15 @@ export const cleanupStaleStatuses = (activeSessionIds: Set): void => { if (!file.endsWith('.json')) continue; const sessionId = file.replace('.json', ''); if (!activeSessionIds.has(sessionId)) { - try { fs.unlinkSync(path.join(STATUS_DIR, file)); } catch {} + console.log(`[session-status] cleanup: removing stale status file ${file}`); + try { fs.unlinkSync(path.join(STATUS_DIR, file)); } catch (err) { + console.error(`[session-status] cleanup: failed to delete ${file}:`, err); + } } } - } catch {} + } catch (err) { + console.error('[session-status] cleanup error:', err); + } }; /** @@ -375,7 +384,9 @@ export const writeStatusFile = (sessionId: string, status: string): void => { const targetFile = path.join(STATUS_DIR, `${sessionId}.json`); fs.writeFileSync(tmpFile, JSON.stringify({ status, timestamp: Math.floor(Date.now() / 1000), cwd: '' })); fs.renameSync(tmpFile, targetFile); - } catch {} + } catch (err) { + console.error(`[session-status] writeStatusFile failed for ${sessionId}:`, err); + } }; export { STATUS_DIR, HOOK_SCRIPT_PATH }; diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index a9b981f..2f0a28a 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -159,87 +159,36 @@ const THEME = { }; /** Enhanced option label formatter - horizontal layout for higher information density */ -const formatOptionLabel = ( - { - value, - label, - everOpened, - }: { value: string; label: string; everOpened: boolean }, - { inputValue }: { inputValue: string }, -) => { - // Split input into search words - const searchWords = (inputValue ?? '') - .split(' ') - .filter((sub: string) => sub); - - // Extract path and name - const path = label?.slice(0, label.lastIndexOf('/')); - let name = label?.slice(label.lastIndexOf('/') + 1); - name = name?.replace(/\.code-workspace/, ' (Workspace)'); - - // Determine styles based on whether the item has been opened - const nameStyle: any = { - fontWeight: '500', - fontSize: '15px', // Increased font size - minWidth: '180px', // Fixed width for project names for better alignment - paddingRight: '10px', - }; - - const pathStyle: any = { - fontSize: '14px', // Increased font size - color: THEME.text.secondary, - flex: 1, - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', - }; - - if (!everOpened) { - nameStyle.color = THEME.text.newItem; - } else { - nameStyle.color = THEME.text.primary; - } - - return ( -
-
- -
-
- -
-
- ); +/** Convert Electron accelerator to macOS symbol string (e.g. "Command+Control+R" → "⌃⌘R") */ +const acceleratorToSymbols = (acc: string): string => + acc + .replace(/Command/g, '⌘') + .replace(/Control/g, '⌃') + .replace(/Alt/g, '⌥') + .replace(/Shift/g, '⇧') + .replace(/\+/g, ''); + +let _homeDir = ''; +let _homePrefix = ''; +// Fetch home dir async on load, cache for sync access +window.electronAPI?.getHomeDir?.().then((dir: string) => { + _homeDir = dir || ''; + _homePrefix = _homeDir ? _homeDir + '/' : ''; +}); +const getHomeDir = (): string => _homeDir; + +/** Replace /Users// with ~/ for display */ +const shortenPath = (p: string): string => { + const home = getHomeDir(); + if (!home) return p; + if (p === home) return '~'; + const prefix = _homePrefix; + return p?.startsWith(prefix) ? '~/' + p.slice(prefix.length) : p; }; +// Note: the unused formatOptionLabel was removed — the inline version +// in the Select component (with branch display + IDE dot) is the one used. + export interface SelectInputOptionInterface { readonly value: string; readonly label: string; @@ -334,7 +283,14 @@ function SwitcherApp() { } }; - const [mode, setMode] = useState('projects'); + // Read initial mode from URL hash (set by main process) to avoid flash + const initialMode = (() => { + const hash = window.location.hash; // e.g. #mode=sessions + const match = hash.match(/mode=(\w+)/); + const m = match?.[1]; + return (m === 'sessions' || m === 'terminal') ? m : 'projects'; + })(); + const [mode, setMode] = useState(initialMode); const [inputValue, setInputValue] = useState(''); const [sessionSearchValue, setSessionSearchValue] = useState(''); const [selectedOptions, setSelectedOptions] = useState([]); @@ -355,7 +311,7 @@ function SwitcherApp() { const [assistantResponses, setAssistantResponses] = useState>({}); const [terminalApps, setTerminalApps] = useState>({}); const [sessionStatuses, setSessionStatuses] = useState>({}); - const modeRef = useRef('projects'); + const modeRef = useRef(initialMode); const activeStateRef = useRef>({}); const allSessionsRef = useRef([]); const lastAssistantFetchRef = useRef>({}); @@ -363,6 +319,7 @@ function SwitcherApp() { const [currentAppMode, setCurrentAppMode] = useState('menubar'); const [modeBanner, setModeBanner] = useState(null); const [quickSwitcherShortcut, setQuickSwitcherShortcut] = useState(''); + const [settingsOpenToTab, setSettingsOpenToTab] = useState<'general' | 'sessions' | 'shortcuts' | null>(null); const bannerTimeoutRef = useRef | null>(null); const updateWorkingPathUIAndList = async (path: string) => { @@ -599,6 +556,11 @@ function SwitcherApp() { setMode('terminal'); }); + // If initial mode is sessions (from URL hash), fetch sessions immediately + if (initialMode === 'sessions') { + fetchClaudeSessions(); + } + // Session status updates from hooks (fs.watch) window.electronAPI.getSessionStatuses().then((rawStatuses: Record) => { if (!rawStatuses) return; @@ -675,10 +637,7 @@ function SwitcherApp() { // Load shortcut for display window.electronAPI.getShortcuts().then((s: any) => { if (s?.quickSwitcher) { - const display = s.quickSwitcher - .replace('Command', 'Cmd') - .replace('Control', 'Ctrl') - .replace(/\+/g, '+'); + const display = acceleratorToSymbols(s.quickSwitcher); setQuickSwitcherShortcut(display); shortcutDisplay = display; } @@ -696,18 +655,24 @@ function SwitcherApp() { const m = mode || 'normal'; setCurrentAppMode(m); if (m === 'normal') { - showBanner('Normal App mode — drag to reposition. Switch to Menu Bar mode in Settings.', 6000); + // Only show the startup banner once (first launch) + window.electronAPI.getBannerSeen().then((seen: boolean) => { + if (!seen) { + showBanner('Normal App mode — drag to reposition. Switch to Menu Bar mode in Settings.', 6000); + window.electronAPI.setBannerSeen(); + } + }); } }); window.electronAPI.onShortcutsUpdated((_event: any, s: any) => { if (s?.quickSwitcher) { - shortcutDisplay = s.quickSwitcher.replace('Command', 'Cmd').replace('Control', 'Ctrl').replace(/\+/g, '+'); + shortcutDisplay = acceleratorToSymbols(s.quickSwitcher); setQuickSwitcherShortcut(shortcutDisplay); } }); window.electronAPI.onAppModeChanged((_event: any, mode: string) => { setCurrentAppMode(mode); - const key = shortcutDisplay || 'Cmd+Ctrl+R'; + const key = shortcutDisplay || '⌃⌘R'; if (mode === 'normal') { showBanner('Switched to Normal App mode — window stays visible and is draggable.'); } else { @@ -903,33 +868,35 @@ function SwitcherApp() { }, input: string, ) => { - // console.log("filterOptions:", candidate?.data) - let allFound = true; + if (!input) return true; let target: string; try { const branch = projectBranches[candidate?.value] || ''; - target = (candidate?.value + ' ' + branch).toLowerCase(); + // Include both full path and ~/shortened path for matching + const shortPath = shortenPath(candidate?.value); + target = (candidate?.value + ' ' + shortPath + ' ' + branch).toLowerCase(); } catch (err) { console.log('target:', candidate); } - if (input) { - const inputArray = input.toLowerCase().split(' '); - for (const subInput of inputArray) { - if (subInput) { - if (!target?.includes(subInput)) { - allFound = false; - break; - } - } + const inputArray = input.toLowerCase().split(' '); + for (const rawSubInput of inputArray) { + // Strip trailing slash for matching (e.g. "~/git/" → "~/git") + const subInput = rawSubInput.endsWith('/') ? rawSubInput.slice(0, -1) : rawSubInput; + if (!subInput) continue; + // Expand ~ to home dir so "~/git/codev" matches "/Users/grimmer/git/codev" + const home = getHomeDir(); + const expanded = subInput.startsWith('~/') && home + ? (home + '/').toLowerCase() + subInput.slice(2) + : subInput === '~' && home + ? home.toLowerCase() + : subInput; + if (!target?.includes(expanded)) { + return false; } - } else { - return true; } - - // false means all filtered (not match) - return allFound; + return true; }; return ( @@ -1011,7 +978,13 @@ function SwitcherApp() { {/* @ts-ignore */}
{quickSwitcherShortcut && ( - + setSettingsOpenToTab('shortcuts')} + title="Click to customize shortcuts" + style={{ fontSize: '10px', color: '#555', cursor: 'pointer' }} + onMouseEnter={(e) => { e.currentTarget.style.color = '#888'; }} + onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; }} + > {quickSwitcherShortcut} )} @@ -1075,6 +1048,8 @@ function SwitcherApp() { setSettingsOpenToTab(null)} saveCallback={(key: string, value: string) => { if (key === 'sessionDisplayMode') { setSessionDisplayMode(value); @@ -1234,9 +1209,9 @@ function SwitcherApp() { const status = sessionStatuses[session.sessionId]; const color = status === 'working' ? '#E8956A' : status === 'idle' ? '#66BB6A' - : status === 'needs-attention' ? '#FFA726' + : status === 'needs-attention' ? '#F06856' : '#CE93D8'; // no status data yet - const animation = status === 'working' ? 'statusPulse 2s ease-in-out infinite' + const animation = status === 'working' ? 'statusPulse 2.5s ease-in-out infinite' : status === 'needs-attention' ? 'statusBlink 1s ease-in-out infinite' : 'none'; return { + const home = getHomeDir(); + const homePrefix = home ? home + '/' : ''; + // Normalize search words: replace home dir prefix with ~/, strip trailing / const searchWords = (searchInput ?? '') .split(' ') - .filter((sub: string) => sub); - const pathPart = label?.slice(0, label.lastIndexOf('/')); + .filter((sub: string) => sub) + .map((w: string) => home && w === home ? '~' : homePrefix && w.startsWith(homePrefix) ? '~/' + w.slice(homePrefix.length) : w) + .map((w: string) => w.endsWith('/') ? w.slice(0, -1) : w) + .filter((w: string) => w); + // Split path tokens into individual segments for highlighting both name and path columns. + // E.g. "~/git/fred-ff-test-token" → ['~', 'git', 'fred-ff-test-token', '~/git/fred-ff-test-token'] + // Both Highlighters get all segments, so each column highlights whatever matches. + const allSegments = searchWords.flatMap((w: string) => + w.includes('/') ? [...w.split('/').filter(Boolean), w] : [w] + ); + // Deduplicate + const highlightWords = [...new Set(allSegments)]; + const pathPart = shortenPath(label?.slice(0, label.lastIndexOf('/'))); let name = label?.slice(label.lastIndexOf('/') + 1); name = name?.replace(/\.code-workspace/, ' (Workspace)'); const branch = projectBranches[value]; @@ -1560,7 +1549,7 @@ function SwitcherApp() {