From 0ccc33f47d5d2ac33a3c92ba6f14264f45a541f1 Mon Sep 17 00:00:00 2001 From: Sun KeyContacts Date: Fri, 22 May 2026 22:30:49 +0800 Subject: [PATCH 1/2] feat: dynamic terminal detection, startup optimization, sort fix - Scan installed terminal apps via CFBundleTypeRole=Shell + known bundle IDs - Settings modal shows only locally installed terminals - Window opens immediately on launch; session indexing runs in background - Add loading spinner during first scan instead of empty state - Fix session detail sort to start from top after toggle - Fix database initialization crash when archived/pinned columns missing Co-Authored-By: Claude Opus 4.7 --- src/main/database.ts | 2 + src/main/index.ts | 11 ++++-- src/main/ipc-handlers.ts | 5 +++ src/main/terminal-scanner.ts | 48 +++++++++++++++++++++++ src/preload/index.ts | 7 ++++ src/renderer/App.tsx | 8 ++++ src/renderer/components/SessionDetail.tsx | 2 +- src/renderer/components/SessionList.tsx | 8 +++- src/renderer/components/SettingsModal.tsx | 16 +++----- src/renderer/lib/i18n.ts | 2 + src/renderer/stores/useStore.ts | 10 +++++ 11 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 src/main/terminal-scanner.ts diff --git a/src/main/database.ts b/src/main/database.ts index 6465f63..682800f 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -32,6 +32,8 @@ export class DatabaseManager { updated_at INTEGER, is_active INTEGER DEFAULT 0, starred INTEGER DEFAULT 0, + archived INTEGER DEFAULT 0, + pinned INTEGER DEFAULT 0, tags TEXT, indexed_at INTEGER ); diff --git a/src/main/index.ts b/src/main/index.ts index a9ad8b0..40bfede 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -44,15 +44,20 @@ app.whenReady().then(async () => { db.initialize() indexer = new Indexer(db) - await indexer.indexAll() - ptyManager = new PtyManager() createWindow() registerIpcHandlers(db, indexer, ptyManager, mainWindowRef) + // Initial index in background so window opens immediately + indexer.indexAll().then(() => { + mainWindowRef.current?.webContents.send('index-ready') + }) + setInterval(() => { - indexer.indexAll() + indexer.indexAll().then(() => { + mainWindowRef.current?.webContents.send('index-ready') + }) }, 5 * 60 * 1000) app.on('activate', () => { diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index b7b9530..ed80463 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -6,6 +6,7 @@ import { UnifiedSession } from '../shared/types' import { getOpencodeMessages } from './indexer/opencode' import { getClaudeMessages } from './indexer/claude' import { getCodexMessages } from './indexer/codex' +import { scanTerminals } from './terminal-scanner' export function registerIpcHandlers( db: DatabaseManager, @@ -117,6 +118,10 @@ export function registerIpcHandlers( return true }) + ipcMain.handle('get-installed-terminals', () => { + return scanTerminals() + }) + ipcMain.handle('resume-session', async (_e, session: UnifiedSession) => { const tabId = `pty-${Date.now()}` const cwd = session.projectPath || process.env.HOME || '/' diff --git a/src/main/terminal-scanner.ts b/src/main/terminal-scanner.ts new file mode 100644 index 0000000..37074a1 --- /dev/null +++ b/src/main/terminal-scanner.ts @@ -0,0 +1,48 @@ +import fs from 'fs' +import path from 'path' +import os from 'os' + +const SCAN_DIRS = ['/Applications', '/System/Applications', '/System/Applications/Utilities', path.join(os.homedir(), 'Applications')] + +const KNOWN_BUNDLE_IDS = [ + 'com.apple.Terminal', + 'com.googlecode.iterm2', + 'dev.warp.Warp', + 'io.alacritty', + 'com.mitchellh.ghostty', + 'com.github.wez.wezterm', + 'org.hyper.Hyper', + 'net.kovidgoyal.kitty', + 'org.tabby', +] + +const EXCLUDE_NAMES = new Set(['iTermBrowserPlugin']) + +export function scanTerminals(): string[] { + const found = new Set() + + for (const dir of SCAN_DIRS) { + let entries: string[] + try { entries = fs.readdirSync(dir) } catch { continue } + + for (const entry of entries) { + if (!entry.endsWith('.app')) continue + const name = entry.replace('.app', '') + if (EXCLUDE_NAMES.has(name)) continue + + const plistPath = path.join(dir, entry, 'Contents/Info.plist') + let content: string + try { content = fs.readFileSync(plistPath, 'utf-8') } catch { continue } + + const isTerminal = + content.includes('Shell') || + KNOWN_BUNDLE_IDS.some(id => content.includes(id)) + + if (isTerminal) found.add(name) + } + } + + if (!found.has('Terminal')) found.add('Terminal') + + return Array.from(found).sort((a, b) => a === 'Terminal' ? -1 : a.localeCompare(b)) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 4468d61..6e12cdf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld('api', { deleteSession: (sessionId: string) => ipcRenderer.invoke('delete-session', sessionId), updateTags: (sessionId: string, tags: string) => ipcRenderer.invoke('update-tags', sessionId, tags), refreshIndex: () => ipcRenderer.invoke('refresh-index'), + getInstalledTerminals: () => ipcRenderer.invoke('get-installed-terminals'), getSessionMessages: (session: unknown) => ipcRenderer.invoke('get-session-messages', session), resumeSession: (session: unknown) => ipcRenderer.invoke('resume-session', session), @@ -36,5 +37,11 @@ contextBridge.exposeInMainWorld('api', { const handler = (_e: unknown, d: unknown) => cb(d as { tabId: string; exitCode: number }) ipcRenderer.on('pty-exit', handler) return () => ipcRenderer.removeListener('pty-exit', handler) + }, + + onIndexReady: (cb: () => void) => { + const handler = () => cb() + ipcRenderer.on('index-ready', handler) + return () => ipcRenderer.removeListener('index-ready', handler) } }) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7ce0b9a..bf589d9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -12,6 +12,7 @@ import { ConfirmModal } from './components/ConfirmModal' export function App() { const loadSessions = useStore(s => s.loadSessions) const loadProjectNames = useStore(s => s.loadProjectNames) + const loadInstalledTerminals = useStore(s => s.loadInstalledTerminals) const showTerminal = useStore(s => s.showTerminal) const terminalFullscreen = useStore(s => s.terminalFullscreen) const theme = useStore(s => s.theme) @@ -24,6 +25,13 @@ export function App() { useEffect(() => { loadSessions() loadProjectNames() + loadInstalledTerminals() + + const unsub = window.api.onIndexReady(() => { + loadSessions() + loadProjectNames() + }) + return unsub }, []) return ( diff --git a/src/renderer/components/SessionDetail.tsx b/src/renderer/components/SessionDetail.tsx index 624e6ea..d4ae9e9 100644 --- a/src/renderer/components/SessionDetail.tsx +++ b/src/renderer/components/SessionDetail.tsx @@ -283,7 +283,7 @@ export function SessionDetail() { key={reverseOrder ? 'rev' : 'fwd'} ref={virtuosoRef} data={displayMessages} - initialTopMostItemIndex={reverseOrder ? displayMessages.length - 1 : 0} + initialTopMostItemIndex={0} itemContent={(index, msg) => } components={{ Header: () =>
, diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index f2d6431..7fad120 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -166,6 +166,7 @@ function getTimeGroup(ts: number | null): string { export function SessionList() { const sessions = useStore(s => s.sessions) + const loading = useStore(s => s.loading) const searchQuery = useStore(s => s.searchQuery) const setSearch = useStore(s => s.setSearch) const sortBy = useStore(s => s.sortBy) @@ -285,7 +286,12 @@ export function SessionList() { {/* Session list */}
- {sortedSessions.length === 0 ? ( + {loading && sortedSessions.length === 0 ? ( +
+
+ {translate('list.loading', lang)} +
+ ) : sortedSessions.length === 0 ? (
{translate('list.noSessions', lang)}
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 7169e22..b30b55c 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -5,16 +5,6 @@ import { useStore } from '../stores/useStore' import { cn } from '../lib/utils' import { translate, LANGUAGES, Lang } from '../lib/i18n' -const TERMINAL_OPTIONS = [ - { value: '', label: 'System Default' }, - { value: 'Warp', label: 'Warp' }, - { value: 'iTerm', label: 'iTerm2' }, - { value: 'Terminal', label: 'Terminal.app' }, - { value: 'Alacritty', label: 'Alacritty' }, - { value: 'Hyper', label: 'Hyper' }, - { value: 'kitty', label: 'kitty' }, -] - function CustomSelect({ value, onChange, options }: { value: string onChange: (v: string) => void @@ -74,6 +64,7 @@ export function SettingsModal() { const setResumeAction = useStore(s => s.setResumeAction) const terminalApp = useStore(s => s.terminalApp) const setTerminalApp = useStore(s => s.setTerminalApp) + const installedTerminals = useStore(s => s.installedTerminals) const language = useStore(s => s.language) const setLanguage = useStore(s => s.setLanguage) @@ -130,7 +121,10 @@ export function SettingsModal() { ({ value: t, label: t })) + ]} />
{terminalApp diff --git a/src/renderer/lib/i18n.ts b/src/renderer/lib/i18n.ts index 56edd7b..7014fea 100644 --- a/src/renderer/lib/i18n.ts +++ b/src/renderer/lib/i18n.ts @@ -68,6 +68,7 @@ const t: Record> = { // SessionList 'list.search': row('Search sessions...', '搜索会话...', { es:'Buscar sesiones...', ar:'بحث الجلسات...', hi:'सत्र खोजें...', pt:'Buscar sessões...', bn:'সেশন খুঁজুন...', ru:'Поиск сессий...', ja:'セッションを検索...', ko:'세션 검색...', fr:'Rechercher...', de:'Sitzungen suchen...', tr:'Oturum ara...', it:'Cerca sessioni...', th:'ค้นหาเซสชัน...', vi:'Tìm kiếm...', pl:'Szukaj sesji...', nl:'Sessies zoeken...', uk:'Пошук сесій...', id:'Cari sesi...' }), + 'list.loading': row('Scanning sessions...', '正在扫描会话...', { es:'Escaneando sesiones...', ar:'جاري مسح الجلسات...', hi:'सत्र स्कैन हो रहे हैं...', pt:'Verificando sessões...', bn:'সেশন স্ক্যান হচ্ছে...', ru:'Сканирование сессий...', ja:'セッションをスキャン中...', ko:'세션 스캔 중...', fr:'Analyse des sessions...', de:'Sitzungen werden gescannt...', tr:'Oturumlar taranıyor...', it:'Scansione sessioni...', th:'กำลังสแกนเซสชัน...', vi:'Đang quét phiên...', pl:'Skanowanie sesji...', nl:'Sessies scannen...', uk:'Сканування сесій...', id:'Memindai sesi...' }), 'list.sort.updated': row('Updated', '更新时间', { es:'Actualizado', ar:'محدث', hi:'अपडेट', pt:'Atualizado', bn:'আপডেট', ru:'Обновлено', ja:'更新', ko:'업데이트', fr:'Mis à jour', de:'Aktualisiert', tr:'Güncellendi', it:'Aggiornato', th:'อัปเดต', vi:'Cập nhật', pl:'Zaktualizowano', nl:'Bijgewerkt', uk:'Оновлено', id:'Diperbarui' }), 'list.sort.created': row('Created', '创建时间', { es:'Creado', ar:'أنشئ', hi:'बनाया', pt:'Criado', bn:'তৈরি', ru:'Создано', ja:'作成', ko:'생성', fr:'Créé', de:'Erstellt', tr:'Oluşturuldu', it:'Creato', th:'สร้าง', vi:'Tạo', pl:'Utworzono', nl:'Aangemaakt', uk:'Створено', id:'Dibuat' }), 'list.sort.rounds': row('Rounds', '对话次数', { es:'Rondas', ar:'جولات', hi:'राउंड', pt:'Rodadas', bn:'রাউন্ড', ru:'Раунды', ja:'回数', ko:'횟수', fr:'Tours', de:'Runden', tr:'Tur', it:'Turni', th:'รอบ', vi:'Vòng', pl:'Rundy', nl:'Ronden', uk:'Раунди', id:'Putaran' }), @@ -146,6 +147,7 @@ const t: Record> = { 'settings.terminal': row('Terminal Application', '终端应用', { es:'Aplicación de terminal', ar:'تطبيق الطرفية', hi:'टर्मिनल एप्लिकेशन', pt:'Aplicativo de terminal', bn:'টার্মিনাল অ্যাপ', ru:'Приложение терминала', ja:'ターミナルアプリ', ko:'터미널 앱', fr:'Application terminal', de:'Terminal-App', tr:'Terminal uygulaması', it:'App terminale', th:'แอปเทอร์มินัล', vi:'Ứng dụng terminal', pl:'Aplikacja terminala', nl:'Terminal-app', uk:'Додаток терміналу', id:'Aplikasi terminal' }), 'settings.terminal.defaultDesc': row('Uses macOS default handler for .command files', '使用 macOS 默认的 .command 文件处理程序', { es:'Usa el controlador predeterminado de macOS', ar:'يستخدم المعالج الافتراضي لـ macOS', hi:'macOS डिफ़ॉल्ट हैंडलर', pt:'Usa o padrão do macOS', bn:'macOS ডিফল্ট ব্যবহার করে', ru:'Использовать macOS по умолчанию', ja:'macOSデフォルトを使用', ko:'macOS 기본 사용', fr:'Utilise le gestionnaire macOS par défaut', de:'macOS-Standard verwenden', tr:'macOS varsayılanını kullan', it:'Usa gestore predefinito macOS', th:'ใช้ค่าเริ่มต้นของ macOS', vi:'Dùng trình xử lý mặc định macOS', pl:'Użyj domyślnej obsługi macOS', nl:'macOS-standaard gebruiken', uk:'Використати типовий обробник macOS', id:'Gunakan penanganan bawaan macOS' }), 'settings.terminal.with': row('Will open with', '将使用', { es:'Se abrirá con', ar:'سيفتح بـ', hi:'इससे खोलेंगे', pt:'Abrirá com', bn:'এটি দিয়ে খুলবে', ru:'Открыть в', ja:'以下で開く', ko:'다음으로 열기', fr:'S\'ouvrira avec', de:'Öffnen mit', tr:'Şununla açılacak', it:'Si aprirà con', th:'เปิดด้วย', vi:'Mở bằng', pl:'Otworzy w', nl:'Opent met', uk:'Відкрити у', id:'Akan dibuka dengan' }), + 'settings.terminal.systemDefault': row('System Default', '系统默认', { es:'Predeterminado', ar:'الافتراضي', hi:'डिफ़ॉल्ट', pt:'Padrão', bn:'ডিফল্ট', ru:'По умолчанию', ja:'デフォルト', ko:'기본', fr:'Par défaut', de:'Standard', tr:'Varsayılan', it:'Predefinito', th:'ค่าเริ่มต้น', vi:'Mặc định', pl:'Domyślny', nl:'Standaard', uk:'Типовий', id:'Bawaan' }), } export type TranslationKey = keyof typeof t diff --git a/src/renderer/stores/useStore.ts b/src/renderer/stores/useStore.ts index fc8c130..cda777b 100644 --- a/src/renderer/stores/useStore.ts +++ b/src/renderer/stores/useStore.ts @@ -28,6 +28,7 @@ declare global { deleteSession: (sessionId: string) => Promise updateTags: (sessionId: string, tags: string) => Promise refreshIndex: () => Promise + getInstalledTerminals: () => Promise getSessionMessages: (session: UnifiedSession) => Promise resumeSession: (session: UnifiedSession) => Promise<{ tabId: string; command: string }> openSystemTerminal: (command: string, cwd?: string, terminalApp?: string) => Promise @@ -37,6 +38,7 @@ declare global { ptyKill: (tabId: string) => Promise onPtyData: (cb: (data: { tabId: string; data: string }) => void) => () => void onPtyExit: (cb: (data: { tabId: string; exitCode: number }) => void) => () => void + onIndexReady: (cb: () => void) => () => void } } } @@ -65,6 +67,7 @@ interface AppState { theme: 'dark' | 'light' resumeAction: 'system' | 'builtin' terminalApp: string + installedTerminals: string[] showSettings: boolean confirmDialog: { message: string; onConfirm: () => void } | null language: Lang @@ -90,6 +93,7 @@ interface AppState { toggleTheme: () => void setResumeAction: (v: 'system' | 'builtin') => void setTerminalApp: (v: string) => void + loadInstalledTerminals: () => Promise setShowSettings: (v: boolean) => void setConfirmDialog: (v: { message: string; onConfirm: () => void } | null) => void confirm: (message: string) => Promise @@ -120,6 +124,7 @@ export const useStore = create((set, get) => ({ theme: (localStorage.getItem('theme') as 'dark' | 'light') || 'dark', resumeAction: (localStorage.getItem('resumeAction') as 'system' | 'builtin') || 'system', terminalApp: localStorage.getItem('terminalApp') || '', + installedTerminals: [], showSettings: false, confirmDialog: null, language: (localStorage.getItem('language') as Lang) || 'en', @@ -310,6 +315,11 @@ export const useStore = create((set, get) => ({ localStorage.setItem('terminalApp', v) }, + loadInstalledTerminals: async () => { + const terminals = await window.api.getInstalledTerminals() + set({ installedTerminals: terminals }) + }, + setShowSettings: (v: boolean) => { set({ showSettings: v }) }, From faadaae146679cf77589e3f0bac438848c5cd39b Mon Sep 17 00:00:00 2001 From: Sun KeyContacts Date: Fri, 22 May 2026 22:31:06 +0800 Subject: [PATCH 2/2] chore: bump version to 0.2.1 Co-Authored-By: Claude Opus 4.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c4ff40..201b091 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "session-manager", - "version": "0.2.0", + "version": "0.2.1", "description": "Unified AI Agent Session Manager", "main": "./out/main/index.js", "scripts": {