From a8abb1cd412348a2d75719f95c287d5f0c2673b3 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Apr 2026 02:10:31 +0800 Subject: [PATCH 01/10] fix: purple dot bug, path display, shortcut symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix #116: session status scan failed silently when JSONL tail exceeded execFile maxBuffer (1MB). Reduced tail from 50 to 15 lines + raised maxBuffer to 5MB. - Display ~/ instead of /Users// in project paths - Search supports ~/ prefix and full path matching - Shortcut uses macOS symbols (⌃⌘R not Cmd+Ctrl+R) - Click shortcut in title bar → Settings Shortcuts tab - Add debug logging to session detection + status scan Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 10 +++++ package.json | 2 +- src/claude-session-utility.ts | 18 +++++--- src/electron-api.d.ts | 1 + src/main.ts | 21 +++++++++- src/popup.tsx | 13 ++++++ src/preload.ts | 1 + src/session-status-hooks.ts | 19 +++++++-- src/switcher-ui.tsx | 78 +++++++++++++++++++++++------------ 9 files changed, 125 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74533e3..7600edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 +- Style: project paths display `~/` instead of `/Users//` +- Style: shortcut display uses macOS symbols (`⌃⌘R` instead of `Cmd+Ctrl+R`) +- Feat: clicking shortcut in title bar opens Settings → Shortcuts tab +- Feat: project search supports `~/` prefix and full path matching + ## 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..6794cc8 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -4,6 +4,7 @@ type IpcCallback = (event: Electron.IpcRendererEvent, ...args: any[]) => void; interface IElectronAPI { // App actions + getHomeDir: () => string; invokeVSCode: (path: string, option: string) => void; hideApp: () => void; openFolderSelector: () => void; diff --git a/src/main.ts b/src/main.ts index f18a8e5..ecc8c73 100644 --- a/src/main.ts +++ b/src/main.ts @@ -619,6 +619,10 @@ ipcMain.on('search-working-folder', (event, path: string) => { } }); +ipcMain.on('get-home-dir', (event) => { + event.returnValue = require('os').homedir(); +}); + ipcMain.on('hide-app', (event) => { hideSwitcherWindow(); }); @@ -1980,6 +1984,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 +1996,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; }); 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..c93b0a0 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,6 +4,7 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { + getHomeDir: () => ipcRenderer.sendSync('get-home-dir'), 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..bbcf331 100644 --- a/src/session-status-hooks.ts +++ b/src/session-status-hooks.ts @@ -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); }); }); @@ -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..16420e1 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -159,6 +159,24 @@ const THEME = { }; /** Enhanced option label formatter - horizontal layout for higher information density */ +/** 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, ''); + +const HOME_DIR = window.electronAPI.getHomeDir(); +const HOME_PREFIX = HOME_DIR + '/'; + +/** Replace /Users// with ~/ for display */ +const shortenPath = (p: string): string => { + if (p === HOME_DIR) return '~'; + return p?.startsWith(HOME_PREFIX) ? '~/' + p.slice(HOME_PREFIX.length) : p; +}; + const formatOptionLabel = ( { value, @@ -172,8 +190,9 @@ const formatOptionLabel = ( .split(' ') .filter((sub: string) => sub); - // Extract path and name - const path = label?.slice(0, label.lastIndexOf('/')); + // Extract path and name, shorten parent path for display + const rawParent = label?.slice(0, label.lastIndexOf('/')); + const parentPath = shortenPath(rawParent); let name = label?.slice(label.lastIndexOf('/') + 1); name = name?.replace(/\.code-workspace/, ' (Workspace)'); @@ -227,7 +246,7 @@ const formatOptionLabel = ( (null); const [quickSwitcherShortcut, setQuickSwitcherShortcut] = useState(''); + const [settingsOpenToTab, setSettingsOpenToTab] = useState<'general' | 'sessions' | 'shortcuts' | null>(null); const bannerTimeoutRef = useRef | null>(null); const updateWorkingPathUIAndList = async (path: string) => { @@ -675,10 +695,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; } @@ -701,13 +718,13 @@ function SwitcherApp() { }); 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 +920,32 @@ 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 subInput of inputArray) { + if (!subInput) continue; + // Expand ~ to home dir so "~/git/codev" matches "/Users/grimmer/git/codev" + const expanded = subInput.startsWith('~/') + ? HOME_PREFIX.toLowerCase() + subInput.slice(2) + : subInput === '~' + ? HOME_DIR.toLowerCase() + : subInput; + if (!target?.includes(expanded)) { + return false; } - } else { - return true; } - - // false means all filtered (not match) - return allFound; + return true; }; return ( @@ -1011,7 +1027,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 +1097,8 @@ function SwitcherApp() { setSettingsOpenToTab(null)} saveCallback={(key: string, value: string) => { if (key === 'sessionDisplayMode') { setSessionDisplayMode(value); From d29b8ba5fa56932f4e72586b4d465fbdc234968c Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Apr 2026 03:13:03 +0800 Subject: [PATCH 02/10] fix: path ~/ display was using dead code formatOptionLabel The actual formatOptionLabel is inline in the Select JSX prop, not the module-scope function. Applied shortenPath() to the inline version and removed the unused module-scope function. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/switcher-ui.tsx | 112 +++++++++----------------------------------- 1 file changed, 22 insertions(+), 90 deletions(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 16420e1..321a760 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -168,96 +168,27 @@ const acceleratorToSymbols = (acc: string): string => .replace(/Shift/g, '⇧') .replace(/\+/g, ''); -const HOME_DIR = window.electronAPI.getHomeDir(); -const HOME_PREFIX = HOME_DIR + '/'; +let _homeDir = ''; +let _homePrefix = ''; +const getHomeDir = (): string => { + if (!_homeDir) { + _homeDir = window.electronAPI?.getHomeDir?.() || ''; + _homePrefix = _homeDir ? _homeDir + '/' : ''; + } + return _homeDir; +}; /** Replace /Users// with ~/ for display */ const shortenPath = (p: string): string => { - if (p === HOME_DIR) return '~'; - return p?.startsWith(HOME_PREFIX) ? '~/' + p.slice(HOME_PREFIX.length) : p; + const home = getHomeDir(); + if (!home) return p; + if (p === home) return '~'; + const prefix = _homePrefix; + return p?.startsWith(prefix) ? '~/' + p.slice(prefix.length) : p; }; -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, shorten parent path for display - const rawParent = label?.slice(0, label.lastIndexOf('/')); - const parentPath = shortenPath(rawParent); - 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 ( -
-
- -
-
- -
-
- ); -}; +// 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; @@ -936,10 +867,11 @@ function SwitcherApp() { for (const subInput of inputArray) { if (!subInput) continue; // Expand ~ to home dir so "~/git/codev" matches "/Users/grimmer/git/codev" - const expanded = subInput.startsWith('~/') - ? HOME_PREFIX.toLowerCase() + subInput.slice(2) - : subInput === '~' - ? HOME_DIR.toLowerCase() + const home = getHomeDir(); + const expanded = subInput.startsWith('~/') && home + ? (home + '/').toLowerCase() + subInput.slice(2) + : subInput === '~' && home + ? home.toLowerCase() : subInput; if (!target?.includes(expanded)) { return false; @@ -1552,7 +1484,7 @@ function SwitcherApp() { const searchWords = (searchInput ?? '') .split(' ') .filter((sub: string) => sub); - const pathPart = label?.slice(0, label.lastIndexOf('/')); + 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]; From a668f2d9bfa23e2cfcfa211e0b720bfeff91c00b Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Apr 2026 03:23:12 +0800 Subject: [PATCH 03/10] style: needs-attention dot color, search highlight, pulse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Needs-attention dot: #FFA726 (orange) → #F06856 (warm red) for clearer distinction from working (#E8956A orange) - Working pulse animation: 2s → 2.5s (slower, more distinct) - Full path search now highlights in shortened ~/ display Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 +++- src/switcher-ui.tsx | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7600edc..3524f38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ - Style: project paths display `~/` instead of `/Users//` - Style: shortcut display uses macOS symbols (`⌃⌘R` instead of `Cmd+Ctrl+R`) - Feat: clicking shortcut in title bar opens Settings → Shortcuts tab -- Feat: project search supports `~/` prefix and full path matching +- Feat: project search supports `~/` prefix and full path matching (with highlight) +- Style: needs-attention dot changed from orange `#FFA726` to warm red `#F06856` for better distinction from working +- Style: working pulse animation slowed from 2s to 2.5s ## 1.0.73 diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 321a760..6b0de70 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -1190,9 +1190,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 + '/' : ''; const searchWords = (searchInput ?? '') .split(' ') - .filter((sub: string) => sub); + .filter((sub: string) => sub) + .map((w: string) => homePrefix && w.startsWith(homePrefix) ? '~/' + w.slice(homePrefix.length) : w); const pathPart = shortenPath(label?.slice(0, label.lastIndexOf('/'))); let name = label?.slice(label.lastIndexOf('/') + 1); name = name?.replace(/\.code-workspace/, ' (Workspace)'); From 3575d55d22b1ef381409f4f9d7a08e2ca0b5c6e8 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Apr 2026 03:48:40 +0800 Subject: [PATCH 04/10] fix: address review comments on PR #117 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cubic P2: sendSync → ipcRenderer.invoke (async) for getHomeDir - cubic P3: update stale comments "50 lines" → "15 lines" - CodeRabbit: split searchWords into nameSearchWords/pathSearchWords so full-path queries highlight correctly in split name/path layout Co-Authored-By: Claude Opus 4.6 (1M context) --- src/electron-api.d.ts | 2 +- src/main.ts | 4 ++-- src/preload.ts | 2 +- src/session-status-hooks.ts | 4 ++-- src/switcher-ui.tsx | 25 ++++++++++++++++--------- 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 6794cc8..3bf4214 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -4,7 +4,7 @@ type IpcCallback = (event: Electron.IpcRendererEvent, ...args: any[]) => void; interface IElectronAPI { // App actions - getHomeDir: () => string; + getHomeDir: () => Promise; invokeVSCode: (path: string, option: string) => void; hideApp: () => void; openFolderSelector: () => void; diff --git a/src/main.ts b/src/main.ts index ecc8c73..e2681f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -619,8 +619,8 @@ ipcMain.on('search-working-folder', (event, path: string) => { } }); -ipcMain.on('get-home-dir', (event) => { - event.returnValue = require('os').homedir(); +ipcMain.handle('get-home-dir', () => { + return require('os').homedir(); }); ipcMain.on('hide-app', (event) => { diff --git a/src/preload.ts b/src/preload.ts index c93b0a0..04eacad 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,7 +4,7 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { - getHomeDir: () => ipcRenderer.sendSync('get-home-dir'), + getHomeDir: () => ipcRenderer.invoke('get-home-dir'), 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 bbcf331..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 @@ -294,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; diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 6b0de70..fa8ea91 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -170,13 +170,12 @@ const acceleratorToSymbols = (acc: string): string => let _homeDir = ''; let _homePrefix = ''; -const getHomeDir = (): string => { - if (!_homeDir) { - _homeDir = window.electronAPI?.getHomeDir?.() || ''; - _homePrefix = _homeDir ? _homeDir + '/' : ''; - } - return _homeDir; -}; +// 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 => { @@ -1483,10 +1482,18 @@ function SwitcherApp() { ) => { const home = getHomeDir(); const homePrefix = home ? home + '/' : ''; + // Normalize search words: replace home dir prefix with ~/ const searchWords = (searchInput ?? '') .split(' ') .filter((sub: string) => sub) .map((w: string) => homePrefix && w.startsWith(homePrefix) ? '~/' + w.slice(homePrefix.length) : w); + // For path-like tokens (e.g. "~/git/codev"), split into segments + // so name and path Highlighters can each match their portion + const nameSearchWords = searchWords.map((w: string) => w.includes('/') ? w.split('/').pop() || w : w); + const pathSearchWords = searchWords.map((w: string) => { + const idx = w.lastIndexOf('/'); + return idx > 0 ? w.slice(0, idx) : w; + }); const pathPart = shortenPath(label?.slice(0, label.lastIndexOf('/'))); let name = label?.slice(label.lastIndexOf('/') + 1); name = name?.replace(/\.code-workspace/, ' (Workspace)'); @@ -1519,7 +1526,7 @@ function SwitcherApp() {
Date: Fri, 10 Apr 2026 04:28:43 +0800 Subject: [PATCH 05/10] fix: path highlight uses full searchWords, not truncated pathSearchWords was cutting at lastIndexOf('/'), causing partial highlight (e.g. ~/git/fred/packages only highlighted ~/git/fred). Now path Highlighter uses full searchWords for correct substring match, name Highlighter uses nameSearchWords (last segment only). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/switcher-ui.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index fa8ea91..a0ec70b 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -1487,13 +1487,8 @@ function SwitcherApp() { .split(' ') .filter((sub: string) => sub) .map((w: string) => homePrefix && w.startsWith(homePrefix) ? '~/' + w.slice(homePrefix.length) : w); - // For path-like tokens (e.g. "~/git/codev"), split into segments - // so name and path Highlighters can each match their portion + // For name Highlighter: extract last path segment so "~/git/codev" highlights "codev" const nameSearchWords = searchWords.map((w: string) => w.includes('/') ? w.split('/').pop() || w : w); - const pathSearchWords = searchWords.map((w: string) => { - const idx = w.lastIndexOf('/'); - return idx > 0 ? w.slice(0, idx) : w; - }); const pathPart = shortenPath(label?.slice(0, label.lastIndexOf('/'))); let name = label?.slice(label.lastIndexOf('/') + 1); name = name?.replace(/\.code-workspace/, ' (Workspace)'); @@ -1562,7 +1557,7 @@ function SwitcherApp() { textAlign: 'right', }}> Date: Fri, 10 Apr 2026 04:47:46 +0800 Subject: [PATCH 06/10] fix: highlight for all path search edge cases - Split path tokens into all segments for both Highlighters - Strip trailing / from search input - Normalize /Users/ (without trailing /) to ~ - Deduplicate highlight words Co-Authored-By: Claude Opus 4.6 (1M context) --- src/switcher-ui.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index a0ec70b..e8fb786 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -1482,13 +1482,21 @@ function SwitcherApp() { ) => { const home = getHomeDir(); const homePrefix = home ? home + '/' : ''; - // Normalize search words: replace home dir prefix with ~/ + // Normalize search words: replace home dir prefix with ~/, strip trailing / const searchWords = (searchInput ?? '') .split(' ') .filter((sub: string) => sub) - .map((w: string) => homePrefix && w.startsWith(homePrefix) ? '~/' + w.slice(homePrefix.length) : w); - // For name Highlighter: extract last path segment so "~/git/codev" highlights "codev" - const nameSearchWords = searchWords.map((w: string) => w.includes('/') ? w.split('/').pop() || w : w); + .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)'); @@ -1521,7 +1529,7 @@ function SwitcherApp() {
Date: Fri, 10 Apr 2026 04:52:41 +0800 Subject: [PATCH 07/10] style: only show normal mode banner on first launch Use localStorage to track if the banner has been shown. Subsequent launches skip the "drag to reposition" banner. Mode-switch banners still show every time (intentional). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/switcher-ui.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index e8fb786..ab31c07 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -643,7 +643,11 @@ 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) + if (!localStorage.getItem('normal-mode-banner-seen')) { + showBanner('Normal App mode — drag to reposition. Switch to Menu Bar mode in Settings.', 6000); + localStorage.setItem('normal-mode-banner-seen', '1'); + } } }); window.electronAPI.onShortcutsUpdated((_event: any, s: any) => { From b45c13183685629014ab74a0da573177c975bb3a Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Apr 2026 05:00:12 +0800 Subject: [PATCH 08/10] fix: persist banner-seen flag via electron-settings localStorage doesn't reliably persist across yarn start restarts in dev mode. Use electron-settings (file-based) instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/electron-api.d.ts | 2 ++ src/main.ts | 8 ++++++++ src/preload.ts | 2 ++ src/switcher-ui.tsx | 10 ++++++---- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 3bf4214..f021ec9 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -5,6 +5,8 @@ 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 e2681f5..da1d95f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2028,6 +2028,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/preload.ts b/src/preload.ts index 04eacad..a3f7bfa 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -5,6 +5,8 @@ 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/switcher-ui.tsx b/src/switcher-ui.tsx index ab31c07..cb9c223 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -644,10 +644,12 @@ function SwitcherApp() { setCurrentAppMode(m); if (m === 'normal') { // Only show the startup banner once (first launch) - if (!localStorage.getItem('normal-mode-banner-seen')) { - showBanner('Normal App mode — drag to reposition. Switch to Menu Bar mode in Settings.', 6000); - localStorage.setItem('normal-mode-banner-seen', '1'); - } + 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) => { From 86648b95d5f2bfc22bf5a60ccfb913600e60763c Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Apr 2026 05:07:27 +0800 Subject: [PATCH 09/10] fix: eliminate tab flash on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass default-switcher-mode via URL hash from main process to renderer, so useState initializes with the correct tab immediately. No more projects→sessions flash. Previously: useState('projects') + async IPC to get default mode → visible tab switch after IPC round trip. Now: main reads setting before createSwitcherWindow, appends #mode=sessions to loadURL, renderer parses hash synchronously. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.ts | 9 ++++++--- src/switcher-ui.tsx | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main.ts b/src/main.ts index da1d95f..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'); @@ -1098,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'); } diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index cb9c223..c3ccfb2 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -283,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([]); @@ -304,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>({}); @@ -549,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; From 81f92811a95ca9f3d05915939bf8891f2eb318f6 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Apr 2026 05:16:05 +0800 Subject: [PATCH 10/10] fix: strip trailing slash in filterOptions, update changelog - filterOptions: strip trailing / from search tokens for defensive matching (mirrors highlight searchWords logic) - CHANGELOG: add tab flash fix, banner, and all style changes Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 8 +++++--- src/switcher-ui.tsx | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3524f38..2043815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,14 @@ - 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: 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) -- Style: needs-attention dot changed from orange `#FFA726` to warm red `#F06856` for better distinction from working -- Style: working pulse animation slowed from 2s to 2.5s ## 1.0.73 diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index c3ccfb2..2f0a28a 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -881,7 +881,9 @@ function SwitcherApp() { } const inputArray = input.toLowerCase().split(' '); - for (const subInput of inputArray) { + 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();