From 154e6aac0e8d73cb0ae5b3bad394932218202371 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 00:10:37 +0800 Subject: [PATCH 01/13] feat(sessions): tty-first switch, memory chip, stale-working, resizable --- CHANGELOG.md | 10 ++++ README.md | 8 +++ package.json | 2 +- src/claude-session-utility.ts | 19 ++++--- src/electron-api.d.ts | 2 + src/live-sessions.test.ts | 27 ++++++++++ src/live-sessions.ts | 53 ++++++++++++++++++- src/main.ts | 50 +++++++++++++++++- src/switcher-ui.tsx | 95 +++++++++++++++++++++++++++++++++-- 9 files changed, 252 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b650104..075325e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.89 + +- Feat: a search result explains itself and can be walked ([#141](https://github.com/grimmerk/codev/issues/141), [#146](https://github.com/grimmerk/codev/issues/146)) + - _(filled in below as the pieces land)_ +- Feat: switch to a running session by its **terminal (tty)** first, title second ([#142](https://github.com/grimmerk/codev/issues/142) C0). Three `/branch` siblings deliberately share a title, and the title-first match sent every one of their rows to the same iTerm2 tab; a process has exactly one tty, so that is what the click matches now (iTerm2 and Terminal.app; Ghostty has no per-tab tty, [#63](https://github.com/grimmerk/codev/issues/63), and keeps title-then-cwd). Running rows that share a title show their tty (`·ttys003`) so they can be told apart on screen +- Feat: a **memory warning chip** beside `● live` when the machine is under pressure — swap past 8GB, or macOS's own pressure level at warn (amber) / critical (red) — with the figures in the live chip's tooltip otherwise. Read from `sysctl vm.swapusage` and `kern.memorystatus_vm_pressure_level` on the same refresh as the process table, so it costs nothing extra. Added the night 42 `claude` processes at 5.1GB pushed a 32GB machine to 18GB of swap: swap was the number that said so first +- Feat: **normal app mode's window can be resized** and reopens at its last position and size (a remembered window that would land on an unplugged display is ignored); the header is already a drag region, so the macOS title-bar double-click action applies to it ([#148](https://github.com/grimmerk/codev/issues/148) step 1; width-aware line caps are step 2) +- Fix: a session parked at Claude Code's context-limit prompt no longer shows as `working` forever — no hook fires there, so a `working` status untouched for 10 minutes is shown as idle ([#110](https://github.com/grimmerk/codev/issues/110)) +- Docs: README on keeping the machine responsive — sessions grow with time, Spotlight should skip `~/Library/Application Support/Claude` and `~/.claude`, and what the swap chip means + ## 1.0.88 - Feat: aim the search — field-scoped terms, PR references in any spelling, `is:live`, and a persisted enrichment cache ([#140](https://github.com/grimmerk/codev/issues/140), [#134](https://github.com/grimmerk/codev/issues/134)) diff --git a/README.md b/README.md index a747281..0cb2139 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,14 @@ For the full same-cwd accuracy matrix (detection + switch by launch method and t | cmux | Title match → TTY fallback | CLI new-workspace | Same as iTerm2 (requires cmux v0.63+); requires socket access in cmux Settings (`automation` or `allowAll`) | | VS Code | URI handler (session-level) | `open -b` + URI handler | Requires Claude Code VS Code extension v2.1.72+; `[VSCODE]` badge on active sessions; adaptive resume via IDE lock file polling (~0.5s if project already open) | +#### Keeping the machine responsive + +Three things learned from a night of cursor stutter on a 32GB machine running ~40 Claude Code sessions: + +- **Sessions grow while they sit.** A Claude Code process gains memory over time even when idle (upstream: [anthropics/claude-code#37240](https://github.com/anthropics/claude-code/issues/37240) measures ~500MB per hour of use, [#18859](https://github.com/anthropics/claude-code/issues/18859) idle sessions reaching ~15GB each). 42 processes held 5.1GB resident and had pushed the machine to 18GB of swap. Close sessions when a task is done, or `save list…` the set, close it all, and `▶ open N` later. When swap passes 8GB or macOS reports memory pressure, a `swap …` chip appears beside `● live` (amber at warn, red at critical); the figures are always in the live chip's tooltip. macOS only empties swap on a restart. +- **Exclude two folders from Spotlight** (System Settings → Spotlight → Search Privacy; press `⌘⇧.` in the file picker to show hidden folders, or `⌘⇧G` and type the path): `~/Library/Application Support/Claude` — Claude Desktop writes gigabytes of IndexedDB churn there and Spotlight re-indexes it ([anthropics/claude-code#43390](https://github.com/anthropics/claude-code/issues/43390), 12GB on the reference machine) — and `~/.claude`, where every running session appends to its transcript. Both showed up as `mds_stores` at 50–100% of a core. +- **A compositor that has run for weeks is a suspect of its own.** With every app closed the stutter remained until a restart; `WindowServer` was at 1.2GB and 30–80% CPU after 37 days of uptime. If nothing in Activity Monitor explains a stutter, restart before debugging further. + ### Multi-Account Support (Claude Code) Run multiple Claude Code accounts (e.g. personal + work) on one machine. Each account gets its own config dir (via `CLAUDE_CONFIG_DIR`); the default account stays at `~/.claude` untouched. The Sessions tab aggregates sessions from every account (non-default ones get a purple account badge), and each session always resumes under the account it belongs to. diff --git a/package.json b/package.json index c246acb..014586c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.88", + "version": "1.0.89", "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 d92a849..4a38c4c 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -1827,11 +1827,14 @@ export const openSessionInITerm2 = async ( : ''; const tmpScript = '/tmp/codev-iterm-switch.scpt'; + // TTY first: a process has exactly one controlling terminal, so this is + // the only match that cannot pick a sibling. Title matching is the + // fallback, and it is exactly what went wrong before (issue #142 C0): + // three `/branch` siblings share a name on purpose, and the title layer + // sent every row to the same tab. const switchScript = `tell application "iTerm2" activate - ${titleMatch ? `-- Layer 1: title matching (most precise for same-cwd sessions) - ${titleMatch.trim()}` : ''} - -- Layer 2: tty matching (fallback) + -- Layer 1: tty matching (exact) set targetTty to do shell script "ps -o tty= -p ${activePid} 2>/dev/null | tr -d '[:space:]'" if targetTty is not "" then repeat with w in windows @@ -1847,6 +1850,8 @@ export const openSessionInITerm2 = async ( end repeat end repeat end if + ${titleMatch ? `-- Layer 2: title matching (fallback; not unique across /branch siblings) + ${titleMatch.trim()}` : ''} return "not found" end tell`; console.log(`[iTerm2] switch: pid=${activePid}, customTitle=${customTitle || 'none'}`); @@ -2553,10 +2558,10 @@ export const openSessionInTerminalApp = async ( const { exec } = require('child_process'); if (isActive && activePid) { - // Two-layer matching: title first, then TTY + // TTY first (exact), title as the fallback — see the iTerm2 switch for why. const titleMatch = customTitle ? ` - -- Layer 1: title matching + -- Layer 2: title matching (fallback; not unique across /branch siblings) repeat with w in windows repeat with t in tabs of w if custom title of t contains "${customTitle.replace(/"/g, '\\"')}" then @@ -2571,8 +2576,7 @@ export const openSessionInTerminalApp = async ( const tmpScript = '/tmp/codev-terminal-switch.scpt'; const switchScript = `tell application "Terminal" activate - ${titleMatch} - -- Layer 2: TTY matching + -- Layer 1: tty matching (exact) set targetTty to do shell script "ps -o tty= -p ${activePid} 2>/dev/null | tr -d '[:space:]'" if targetTty is not "" then repeat with w in windows @@ -2585,6 +2589,7 @@ export const openSessionInTerminalApp = async ( end repeat end repeat end if + ${titleMatch} return "not found" end tell`; console.log(`[Terminal.app] switch: pid=${activePid}, customTitle=${customTitle || 'none'}`); diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 6c238e4..a40f6b1 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -215,6 +215,8 @@ interface IElectronAPI { staleRegistrations: { pid: number; sessionId: string; cwd: string }[]; totalRssKb: number; measuredAt: number; + /** Machine-wide swap and pressure level (1 normal, 2 warn, 4 critical); absent when unreadable. */ + memory?: { swapUsedMb: number; swapTotalMb: number; level: number }; }>; // Claude Code sessions diff --git a/src/live-sessions.test.ts b/src/live-sessions.test.ts index ed4ee47..6f9d231 100644 --- a/src/live-sessions.test.ts +++ b/src/live-sessions.test.ts @@ -5,11 +5,31 @@ import { isSessionProcess, joinLiveSessions, parseEtime, + parseMemoryPressure, parsePsOutput, sessionIdFromArgs, SessionRegistration, } from './live-sessions'; +describe('parseMemoryPressure', () => { + it('reads the swap line and the pressure level, converting units to MB', () => { + expect( + parseMemoryPressure( + 'vm.swapusage: total = 14336.00M used = 13066.94M free = 1221.06M (encrypted)\n2\n', + ), + ).toEqual({ swapUsedMb: 13066.94, swapTotalMb: 14336, level: 2 }); + expect( + parseMemoryPressure('total = 2.00G used = 512.00M free = 1.50G\n1'), + ).toEqual({ swapUsedMb: 512, swapTotalMb: 2048, level: 1 }); + }); + + it('returns null rather than zeros when either figure is missing', () => { + expect(parseMemoryPressure('')).toBeNull(); + expect(parseMemoryPressure('1\n')).toBeNull(); + expect(parseMemoryPressure('total = 1.00M used = 0.50M free = 0.50M\n')).toBeNull(); + }); +}); + // Captured from `ps -Ao pid=,rss=,tty=,etime=,args=` on 2026-09-05, trimmed // to the shapes that matter: sessions with a tty, the daemon family without // one, a versioned binary path, and an unrelated process. @@ -255,6 +275,13 @@ describe('collectLiveSessions', () => { asked.push(pid); return `/cwd/of/${pid}`; }, + sysctl: async () => + 'vm.swapusage: total = 14336.00M used = 13066.94M free = 1221.06M (encrypted)\n4\n', + }); + expect(report.memory).toEqual({ + swapUsedMb: 13066.94, + swapTotalMb: 14336, + level: 4, }); // Every tty-attached session except the registered one is unregistered here. expect(asked.sort((a, b) => a - b)).toEqual([ diff --git a/src/live-sessions.ts b/src/live-sessions.ts index 6becac8..1f336c9 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -60,14 +60,52 @@ export interface LiveSession { accountIsAnchor?: boolean; } +/** + * What the machine as a whole is doing about memory, so the live view can + * warn before the cursor starts to stutter. Measured the night this was + * added: 42 `claude` processes at 5.1GB pushed a 32GB machine to 18GB of + * swap and the compositor to 40–80% CPU; the number that said so first was + * swap, not any process's RSS. + */ +export interface MemoryPressure { + swapUsedMb: number; + swapTotalMb: number; + /** `kern.memorystatus_vm_pressure_level`: 1 normal, 2 warn, 4 critical; 0 when unreadable. */ + level: number; +} + export interface LiveSessionsReport { live: LiveSession[]; /** Registered pids whose process is gone — a stale file, shown as a ghost by anything that trusts it. */ staleRegistrations: { pid: number; sessionId: string; cwd: string }[]; totalRssKb: number; measuredAt: number; + /** Absent when `sysctl` could not be read; never guessed. */ + memory?: MemoryPressure; } +/** + * Parse the two lines `sysctl -n vm.swapusage kern.memorystatus_vm_pressure_level` + * prints, e.g. `total = 14336.00M used = 13066.94M free = 1221.06M (encrypted)` + * and `1`. Null when either is missing — a missing figure is not zero. + */ +export const parseMemoryPressure = (out: string): MemoryPressure | null => { + const lines = out.split('\n').map((l) => l.trim()); + const swap = lines.find((l) => l.includes('used =')); + const levelLine = lines.find((l) => /^\d+$/.test(l)); + if (!swap || !levelLine) return null; + const num = (label: string): number | null => { + const m = new RegExp(`${label} = ([0-9.]+)([KMG])`).exec(swap); + if (!m) return null; + const v = Number(m[1]); + return m[2] === 'G' ? v * 1024 : m[2] === 'K' ? v / 1024 : v; + }; + const used = num('used'); + const total = num('total'); + if (used === null || total === null) return null; + return { swapUsedMb: used, swapTotalMb: total, level: Number(levelLine) }; +}; + /** `[[dd-]hh:]mm:ss` as `ps -o etime` prints it. */ export const parseEtime = (s: string): number => { const m = s.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/); @@ -312,6 +350,8 @@ export interface CollectDeps { ps?: () => Promise; readRegistrations?: () => SessionRegistration[]; cwdOf?: (pid: number) => Promise; + /** `sysctl -n vm.swapusage kern.memorystatus_vm_pressure_level`; empty on failure. */ + sysctl?: () => Promise; } export const collectLiveSessions = async ( @@ -323,8 +363,17 @@ export const collectLiveSessions = async ( execFileP('ps', ['-Ao', 'pid=,rss=,tty=,etime=,args='], PS_TIMEOUT_MS)); const readRegs = deps.readRegistrations ?? readSessionRegistrations; const cwdOf = deps.cwdOf ?? lsofCwd; + const sysctl = + deps.sysctl ?? + (() => + execFileP( + 'sysctl', + ['-n', 'vm.swapusage', 'kern.memorystatus_vm_pressure_level'], + 2000, + )); - const procs = parsePsOutput(await ps()); + const [psOut, sysctlOut] = await Promise.all([ps(), sysctl()]); + const procs = parsePsOutput(psOut); // A process table is never empty — `ps` lists at least itself — so an empty // parse means the call failed or timed out. Report that rather than a // fabricated "nothing is running". @@ -339,5 +388,7 @@ export const collectLiveSessions = async ( s.cwd = await cwdOf(s.pid); }), ); + const memory = parseMemoryPressure(sysctlOut); + if (memory) report.memory = memory; return report; }; diff --git a/src/main.ts b/src/main.ts index 9c95b70..c10915e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -456,7 +456,12 @@ const createSwitcherWindow = (initialMode?: string): BrowserWindow => { show: false, frame: false, fullscreenable: false, - resizable: false, + // Normal app mode is a regular window on a large display: let it be + // resized (issue #148, step 1) and remember where it was. The menu-bar + // popup stays fixed-size — it is placed by the app, not the user. + resizable: appMode === 'normal', + minWidth: 640, + minHeight: 420, backgroundColor: '#1e1e1e', }); @@ -465,6 +470,49 @@ const createSwitcherWindow = (initialMode?: string): BrowserWindow => { const hash = initialMode ? `#mode=${initialMode}` : ''; window.loadURL(SWITCHER_WINDOW_WEBPACK_ENTRY + hash); + if (appMode === 'normal') { + // Restore the last bounds before the window is first shown. Only if + // they still land on a display that exists — a window remembered on an + // external monitor must not open off-screen after it is unplugged. + settings + .get('switcher-window-bounds') + .then((saved: unknown) => { + const b = saved as { x: number; y: number; width: number; height: number } | null; + if ( + !b || + typeof b.x !== 'number' || + typeof b.y !== 'number' || + typeof b.width !== 'number' || + typeof b.height !== 'number' || + window.isDestroyed() + ) { + return; + } + const display = screen.getDisplayMatching(b); + const area = display.workArea; + const onScreen = + b.x < area.x + area.width && + b.x + b.width > area.x && + b.y < area.y + area.height && + b.y + b.height > area.y; + if (onScreen) window.setBounds({ ...b, width: Math.max(b.width, 640), height: Math.max(b.height, 420) }); + }) + .catch(() => {}); + let saveBoundsTimer: ReturnType | null = null; + const saveBounds = () => { + if (saveBoundsTimer) clearTimeout(saveBoundsTimer); + saveBoundsTimer = setTimeout(() => { + if (window.isDestroyed()) return; + const b = window.getBounds(); + settings + .set('switcher-window-bounds', { x: b.x, y: b.y, width: b.width, height: b.height }) + .catch(() => {}); + }, 400); + }; + window.on('resize', saveBounds); + window.on('move', saveBounds); + } + // Open external links in default browser const { shell } = require('electron'); window.webContents.setWindowOpenHandler(({ url }: { url: string }) => { diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 122bcae..2b26d10 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -522,6 +522,28 @@ const formatUptime = (sec: number): string => { return `${m}m`; }; +// A `working` status file that no hook has touched for this long is a +// session parked at a prompt Claude Code fires no hook for — the context- +// limit "new task? /clear …" prompt (issue #110) is the known case. Ten +// minutes is longer than any tool call this app has seen finish, and the +// cost of being wrong is one dot that is green a little early. +const STALE_WORKING_SEC = 10 * 60; + +/** The dot colour for one status entry, with the stale-working rule applied. */ +const dotStatus = (v: { status?: string; timestamp?: number } | string): string => { + if (typeof v !== 'object' || !v) return v as string; + const status = v.status ?? ''; + if ( + status === 'working' && + typeof v.timestamp === 'number' && + v.timestamp > 0 && + Date.now() / 1000 - v.timestamp > STALE_WORKING_SEC + ) { + return 'idle'; + } + return status; +}; + const formatMb = (kb: number): string => { const mb = kb / 1024; return mb >= 1024 ? `${(mb / 1024).toFixed(1)}GB` : `${Math.round(mb)}MB`; @@ -1175,6 +1197,34 @@ function SwitcherApp() { const liveCount = liveReport ? liveReport.live.length : Object.keys(activeStateRef.current).length; + // Swap and pressure level from the live report. A chip only when the + // machine is actually under pressure (level warn/critical, or swap past + // 8GB — the reference machine was stuttering at 13–18GB); the plain + // figures live in the live chip's tooltip so the row stays quiet otherwise. + const memory = liveReport?.memory; + const memorySummary = memory + ? `swap ${formatMb(memory.swapUsedMb * 1024)} of ${formatMb(memory.swapTotalMb * 1024)} · pressure ${memory.level >= 4 ? 'critical' : memory.level >= 2 ? 'warn' : 'normal'}` + : ''; + const memoryWarning = + memory && (memory.level >= 2 || memory.swapUsedMb > 8 * 1024) + ? { + critical: memory.level >= 4, + label: `${memory.level >= 4 ? '⚠ ' : ''}swap ${formatMb(memory.swapUsedMb * 1024)}`, + detail: memorySummary, + } + : null; + // Live rows that share a title (typically /branch siblings, issue #142 C0) + // get a tty tag so they can be told apart on screen; the tty is also what + // the switch now matches on, so the tag names the thing the click uses. + const liveTitleDupes = (() => { + const seen = new Map(); + for (const s of displayedSessions) { + if (!s.isActive) continue; + const t = customTitles[s.sessionId] || s.__listMember?.title; + if (t) seen.set(t, (seen.get(t) ?? 0) + 1); + } + return new Set([...seen].filter(([, n]) => n > 1).map(([t]) => t)); + })(); const staleCount = liveReport?.staleRegistrations.length ?? 0; // Memory of the rows on screen — not of every live process — so the figure // beside a search result describes the result. @@ -1895,7 +1945,7 @@ function SwitcherApp() { if (!rawStatuses) return; const statusStrings: Record = {}; for (const [id, v] of Object.entries(rawStatuses)) { - statusStrings[id] = typeof v === 'object' ? v.status : v; + statusStrings[id] = dotStatus(v); } setSessionStatuses(statusStrings); }); @@ -1903,7 +1953,7 @@ function SwitcherApp() { // Extract status strings for dots display const statusStrings: Record = {}; for (const [id, v] of Object.entries(rawStatuses)) { - statusStrings[id] = typeof v === 'object' ? v.status : v; + statusStrings[id] = dotStatus(v); } setSessionStatuses(statusStrings); @@ -2062,7 +2112,7 @@ function SwitcherApp() { if (!rawStatuses) return; const statusStrings: Record = {}; for (const [id, v] of Object.entries(rawStatuses)) { - statusStrings[id] = typeof v === 'object' ? v.status : v; + statusStrings[id] = dotStatus(v); } setSessionStatuses(statusStrings); }); @@ -2591,7 +2641,7 @@ function SwitcherApp() { aria-pressed={liveOnlyActive} title={ liveOnlyActive - ? `Show every session again${liveReport ? ` · measured ${formatRelativeTime(liveReport.measuredAt)}; re-read on each open and on toggling` : ''}` + ? `Show every session again${liveReport ? ` · measured ${formatRelativeTime(liveReport.measuredAt)}; re-read on each open and on toggling${memorySummary ? ` · ${memorySummary}` : ''}` : ''}` : `Show only sessions with a running process${staleCount ? ` · ${staleCount} stale registration${staleCount > 1 ? 's' : ''} in ~/.claude/sessions` : ''}` } onMouseDown={(e) => e.preventDefault()} @@ -2606,6 +2656,23 @@ function SwitcherApp() { > ● {liveCount} live{staleCount > 0 ? ` ⚠${staleCount}` : ''} + {/* Machine-wide memory, shown only when it is a problem: swap is + what said "trouble" first the night 42 claude processes + pushed a 32GB machine to 18GB of swap. Normal pressure stays + in the live chip's tooltip. */} + {memoryWarning && ( + + {memoryWarning.label} + + )} {liveOnlyActive && ( + {session.isActive && + liveTitleDupes.has( + customTitles[session.sessionId] || session.__listMember?.title || '', + ) && + (() => { + const live = session.__live || liveBySession[session.sessionId]; + const tag = live?.tty + ? live.tty + : live?.pid || session.activePid + ? `pid ${live?.pid ?? session.activePid}` + : null; + return tag ? ( + + {' '}·{tag} + + ) : null; + })()} )} {(branches[session.sessionId] || session.__listMember?.branch) && ( From da8584592b3d935f7e11c68c90194ac1f7dfa818 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 00:13:41 +0800 Subject: [PATCH 02/13] feat(sessions): every hit, its time and context; why a row matched --- CHANGELOG.md | 5 +- README.md | 2 + src/claude-session-utility.ts | 50 +++++++- src/electron-api.d.ts | 18 ++- src/live-sessions.test.ts | 4 +- src/session-search.test.ts | 78 +++++++++++++ src/session-search.ts | 97 ++++++++++++++++ src/switcher-ui.tsx | 213 +++++++++++++++++++++++++++++----- 8 files changed, 433 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 075325e..3f94a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ ## 1.0.89 - Feat: a search result explains itself and can be walked ([#141](https://github.com/grimmerk/codev/issues/141), [#146](https://github.com/grimmerk/codev/issues/146)) - - _(filled in below as the pieces land)_ + - **Every hit, not the first.** The `match #N` line gains `‹ 2/12 ›` when a session's prompts hit more than once, stepping the snippet through them; the main-side search now returns up to 20 hits per session, each with its prompt time + - **The prompts around a hit**, one click (`▸`) away on the match line: the prompt before (`↑`) and after (`↓`). The smallest useful version of the reader in #66 — user prompts only; assistant text is not in the index + - **`by match`** chip while searching: order results by when the match happened instead of the session's last activity, so the session where you typed the word an hour ago is not buried under one touched five minutes ago. Off by default — "what was I just working on" is the commoner question + - **`match path` / `match assistant` / `match recap` / `match reply`** lines say which field a row matched in when that field is not on the row (the path, the assistant's mined references, a recap the row is not showing, a reply hidden behind a recap). Fields that render — title, branch, project name, badge, first/last prompt — already carry the highlight, so they add no line: vertical space stays the scarce resource - Feat: switch to a running session by its **terminal (tty)** first, title second ([#142](https://github.com/grimmerk/codev/issues/142) C0). Three `/branch` siblings deliberately share a title, and the title-first match sent every one of their rows to the same iTerm2 tab; a process has exactly one tty, so that is what the click matches now (iTerm2 and Terminal.app; Ghostty has no per-tab tty, [#63](https://github.com/grimmerk/codev/issues/63), and keeps title-then-cwd). Running rows that share a title show their tty (`·ttys003`) so they can be told apart on screen - Feat: a **memory warning chip** beside `● live` when the machine is under pressure — swap past 8GB, or macOS's own pressure level at warn (amber) / critical (red) — with the figures in the live chip's tooltip otherwise. Read from `sysctl vm.swapusage` and `kern.memorystatus_vm_pressure_level` on the same refresh as the process table, so it costs nothing extra. Added the night 42 `claude` processes at 5.1GB pushed a 32GB machine to 18GB of swap: swap was the number that said so first - Feat: **normal app mode's window can be resized** and reopens at its last position and size (a remembered window that would land on an unplugged display is ignored); the header is already a drag region, so the macOS title-bar double-click action applies to it ([#148](https://github.com/grimmerk/codev/issues/148) step 1; width-aware line caps are step 2) diff --git a/README.md b/README.md index 0cb2139..d3fee22 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Bare words search everything; **operators aim the query** (the `?` chip beside t Every term must hold. An operator with an unreadable value (`after:soon`) is reported under the box and ignored rather than silently matching nothing. +A result also says **why and when**: the `match #N` line steps through every prompt hit in a session (`‹ 2/12 ›`) and unfolds (`▸`) the prompt before and after the hit; a `by match` chip orders results by when the match happened rather than by the session's last activity; and when the matching field is not on the row — the project path, something the assistant said, a recap the row is not showing — a `match path` / `match assistant` / `match recap` / `match reply` line names it. Fields that are on the row (title, branch, project name, PR badge, first/last prompt) already carry the highlight and add no line. + #### Finding a pull request A PR can be found from two kinds of evidence, both indexed: diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 4a38c4c..c929af8 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -17,9 +17,12 @@ import { } from './accounts'; import { compileQuery, - findPromptMatch, + explainMatch, + findPromptHits, isEmptyQuery, + MatchField, parseQuery, + PromptHit, PromptMatch, promptNeedles, sessionRepos, @@ -91,6 +94,9 @@ const CACHE_TTL_MS = 5000; // refresh cache after 5 seconds // All user prompts per session, same rebuild lifecycle as cachedSessions. // Main-process-only: searched here, never shipped over IPC (~MBs of text). let promptsBySession: Map = new Map(); +// Epoch ms per prompt, parallel to promptsBySession (issue #146: a hit +// carries the time it happened, not just the session it happened in). +let promptTimesBySession: Map = new Map(); // Cache for active session detection to avoid spawning processes on every keystroke let cachedActiveMap: Map | null = null; @@ -140,6 +146,7 @@ export const readClaudeSessions = (limit = 100): ClaudeSession[] => { // sessionIds are UUIDs (unique across accounts), so no cross-account dedupe. const bySession = new Map(); const prompts = new Map(); + const promptTimes = new Map(); // Per-account try/catch: one unreadable/corrupt history must not hide the // sessions of every other account. @@ -159,6 +166,10 @@ export const readClaudeSessions = (limit = 100): ClaudeSession[] => { const list = prompts.get(raw.sessionId); if (list) list.push(raw.display); else prompts.set(raw.sessionId, [raw.display]); + const times = promptTimes.get(raw.sessionId); + const at = typeof raw.timestamp === 'number' ? raw.timestamp : 0; + if (times) times.push(at); + else promptTimes.set(raw.sessionId, [at]); } const existing = bySession.get(raw.sessionId); @@ -220,12 +231,19 @@ export const readClaudeSessions = (limit = 100): ClaudeSession[] => { cachedSessions = allSessions; promptsBySession = prompts; + promptTimesBySession = promptTimes; cacheTimestamp = now; return allSessions.slice(0, limit); }; export interface SessionSearchMatch extends PromptMatch { isLastPrompt: boolean; + /** Every hit (up to 20), first one mirrored in the fields above. */ + hits: PromptHit[]; + /** Epoch ms of the latest hit — what "sort by match" orders on. */ + matchedAt: number; + /** Which fields matched, for the "why is this row here" marker (#141). */ + reasons: MatchField[]; } export interface SessionSearchResult { @@ -303,16 +321,38 @@ export const searchClaudeSessions = ( } sessions.push(s); - const match = findPromptMatch( + const reasons = explainMatch( + { + sessionId: id, + text, + title, + branch, + project: s.projectName, + path: s.project, + recap, + prompts: sessionPrompts, + assistant: refs?.join(' '), + prText: prLink ? `PR #${prLink.prNumber} ${prLink.prUrl}` : undefined, + repos, + }, + parsed, + ); + const hits = findPromptHits( sessionPrompts, + promptTimesBySession.get(id) || [], needles, parsed.prRefs, repos, ); - if (match) { + if (hits.length > 0 || reasons.length > 0) { + const first = hits[0]; snippets[id] = { - ...match, - isLastPrompt: match.promptIndex === sessionPrompts.length - 1, + promptIndex: first?.promptIndex ?? -1, + snippet: first?.snippet ?? '', + isLastPrompt: !!first && first.promptIndex === sessionPrompts.length - 1, + hits, + matchedAt: hits.reduce((m, h) => Math.max(m, h.at), 0), + reasons, }; } if (sessions.length >= limit) break; diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index a40f6b1..0808a2d 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -225,7 +225,23 @@ interface IElectronAPI { sessions: any[]; snippets: Record< string, - { snippet: string; promptIndex: number; isLastPrompt: boolean } + { + snippet: string; + promptIndex: number; + isLastPrompt: boolean; + /** Every prompt hit (up to 20): where, when, and the prompts around it. */ + hits: { + promptIndex: number; + snippet: string; + at: number; + before?: string; + after?: string; + }[]; + /** Epoch ms of the latest hit; 0 when the match was not in a prompt. */ + matchedAt: number; + /** Fields the query matched in: title, branch, project, path, prompt, recap, reply, assistant, pr, id. */ + reasons: string[]; + } >; }>; detectActiveSessions: () => Promise<{ diff --git a/src/live-sessions.test.ts b/src/live-sessions.test.ts index 6f9d231..6734247 100644 --- a/src/live-sessions.test.ts +++ b/src/live-sessions.test.ts @@ -26,7 +26,9 @@ describe('parseMemoryPressure', () => { it('returns null rather than zeros when either figure is missing', () => { expect(parseMemoryPressure('')).toBeNull(); expect(parseMemoryPressure('1\n')).toBeNull(); - expect(parseMemoryPressure('total = 1.00M used = 0.50M free = 0.50M\n')).toBeNull(); + expect( + parseMemoryPressure('total = 1.00M used = 0.50M free = 0.50M\n'), + ).toBeNull(); }); }); diff --git a/src/session-search.test.ts b/src/session-search.test.ts index 63b2ff0..3fe0025 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'; import { compileQuery, emptyQuery, + explainMatch, + findPromptHits, extractSnippet, findPrRef, findPromptMatch, @@ -743,3 +745,79 @@ describe('findPromptMatch with PR references', () => { }); }); }); + +describe('findPromptHits', () => { + const prompts = [ + 'setup', + 'see #147 first', + 'unrelated', + 'again #147 and pr2', + 'done', + ]; + const times = [1, 2, 3, 4, 5]; + + it('returns every hit with its time and neighbours, in order', () => { + const hits = findPromptHits(prompts, times, ['pr2'], [{ number: 147 }]); + expect(hits.map((h) => [h.promptIndex, h.at])).toEqual([ + [1, 2], + [3, 4], + ]); + expect(hits[0].before).toBe('setup'); + expect(hits[0].after).toBe('unrelated'); + expect(hits[1].after).toBe('done'); + expect(hits[1].snippet).toContain('#147'); + }); + + it('has no neighbour past either end, caps long context, and honours the limit', () => { + const one = findPromptHits(['x'.repeat(300) + ' hit'], [9], ['hit']); + expect(one[0].before).toBeUndefined(); + expect(one[0].after).toBeUndefined(); + const many = findPromptHits( + Array(30).fill('hit here'), + [], + ['hit'], + [], + undefined, + 5, + ); + expect(many).toHaveLength(5); + const ctx = findPromptHits(['a'.repeat(400), 'hit'], [1, 2], ['hit']); + expect(ctx[0].before?.length).toBe(201); + expect(ctx[0].before?.endsWith('…')).toBe(true); + }); +}); + +describe('explainMatch', () => { + const q = parseQuery('pr2 #147', 0); + it('names each field a word or reference matched in, once, and only fields supplied', () => { + const fields = explainMatch( + { + sessionId: 'abcd1234-0000', + text: '', + title: 'harden again - pr2-1533', + branch: 'feat-pr2', + path: '/Users/g/git/pr2', + prompts: ['open #147', 'pr2 again'], + assistant: 'grimmerk/codev#147', + recap: 'nothing here', + }, + q, + ); + expect(fields.sort()).toEqual([ + 'assistant', + 'branch', + 'path', + 'prompt', + 'title', + ]); + }); + + it('reports the session id when a word is an id prefix', () => { + expect( + explainMatch( + { sessionId: 'abcd1234-0000', text: '' }, + parseQuery('abcd', 0), + ), + ).toEqual(['id']); + }); +}); diff --git a/src/session-search.ts b/src/session-search.ts index cea49cc..ae1b4b8 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -579,6 +579,103 @@ export const findPromptMatch = ( return null; }; +/** One hit in a session's prompt list (issue #146): where, when, and its neighbours. */ +export interface PromptHit extends PromptMatch { + /** Epoch ms of the matched prompt, 0 when unknown. */ + at: number; + /** The prompt before / after the hit, for the context lines; capped. */ + before?: string; + after?: string; +} + +const CONTEXT_CAP = 200; +const capContext = (s: string | undefined): string | undefined => + s === undefined + ? undefined + : s.length > CONTEXT_CAP + ? `${s.slice(0, CONTEXT_CAP)}…` + : s; + +/** + * EVERY prompt a query hits, in order, up to `limit` — `findPromptMatch` + * returns only the first, which is why a session with twelve hits used to + * show one. Each hit carries its time (for sorting by when the match + * happened rather than by the session's last activity) and its + * neighbouring prompts (the smallest useful version of a reader). + */ +export const findPromptHits = ( + prompts: string[], + times: number[], + wordsLower: string[], + prRefs: PrRef[] = [], + repos?: string[], + limit = 20, +): PromptHit[] => { + const hits: PromptHit[] = []; + for (let i = 0; i < prompts.length && hits.length < limit; i++) { + const one = findPromptMatch([prompts[i]], wordsLower, prRefs, repos); + if (!one) continue; + hits.push({ + promptIndex: i, + snippet: one.snippet, + at: times[i] ?? 0, + before: capContext(i > 0 ? prompts[i - 1] : undefined), + after: capContext(i + 1 < prompts.length ? prompts[i + 1] : undefined), + }); + } + return hits; +}; + +/** Which fields of a target a query touched — the answer to "why is this row here" (issue #141). */ +export type MatchField = + | 'title' + | 'branch' + | 'project' + | 'path' + | 'prompt' + | 'recap' + | 'reply' + | 'assistant' + | 'pr' + | 'id'; + +/** + * The fields a query matched in, for a target that already passed the + * matcher. Every bare word and PR reference is checked against each named + * field separately; a field is listed once. Fields the caller did not supply + * are simply absent, so each side reports what it can see. + */ +export const explainMatch = ( + t: QueryTarget & { + path?: string; + reply?: string; + assistant?: string; + prText?: string; + }, + q: ParsedQuery, +): MatchField[] => { + const fields: [MatchField, string | undefined][] = [ + ['title', t.title], + ['branch', t.branch], + ['project', t.project], + ['path', t.path], + ['prompt', t.prompts?.join('\n')], + ['recap', t.recap], + ['reply', t.reply], + ['assistant', t.assistant], + ['pr', t.prText], + ]; + const out = new Set(); + for (const [name, raw] of fields) { + if (!raw) continue; + const lower = raw.toLowerCase(); + if (q.words.some((w) => lower.includes(w))) out.add(name); + if (q.prRefs.some((r) => findPrRef(lower, r, t.repos))) out.add(name); + } + if (q.words.some((w) => matchesSessionId(t.sessionId, w))) out.add('id'); + return [...out]; +}; + /** * Minor-session ("junk") folding predicate: a closed session with almost no * content and no user-assigned identity. Conservative on purpose — sessions diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 2b26d10..c61481c 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -26,6 +26,10 @@ import { import TerminalTab from './terminal-tab'; type LiveReport = Awaited>; +/** One session's search result detail: hits, their time, and which fields matched. */ +type SearchHit = Awaited< + ReturnType +>['snippets'][string]; type ListsResponse = Awaited>; /** Shape every list mutation returns (save / delete / rename). */ type ListsWriteResult = { @@ -655,7 +659,18 @@ function SwitcherApp() { const [assistantResponses, setAssistantResponses] = useState>({}); const [terminalApps, setTerminalApps] = useState>({}); const [sessionStatuses, setSessionStatuses] = useState>({}); - const [searchSnippets, setSearchSnippets] = useState>({}); + const [searchSnippets, setSearchSnippets] = useState>({}); + // Mirrored for applySearchFilter (stale-closure trap, see sessionSearchRef2). + const searchSnippetsRef = useRef>({}); + // Issue #146: order results by when the match happened instead of the + // session's last activity. Off by default — "what was I just working on" + // is the commoner question, and it wants last activity. + const [sortByMatch, setSortByMatch] = useState(false); + const sortByMatchRef = useRef(false); + // Which of a session's hits the row shows, and whether its context + // (the prompts before and after) is unfolded. + const [hitIndex, setHitIndex] = useState>({}); + const [expandedHits, setExpandedHits] = useState>(new Set()); const [minorsExpanded, setMinorsExpanded] = useState(false); // Folding waits for the first active-session detection so a just-started // (≤2 msgs, not-yet-detected) session is never folded away at app start. @@ -944,11 +959,16 @@ function SwitcherApp() { isPinned: s.sessionId in sessionMarks.pins, }), ); - if (extra.length === 0) return base; - const merged = [...base, ...extra]; - merged.sort( - (a: any, b: any) => (b.lastTimestamp || 0) - (a.lastTimestamp || 0), - ); + // Base rows arrive in timeline order; only a merge or a match-time sort + // needs a re-sort. Match time falls back to last activity for a row whose + // match was not in a prompt (title, branch, badge). + if (extra.length === 0 && !sortByMatchRef.current) return base; + const merged = extra.length === 0 ? [...base] : [...base, ...extra]; + const key = (s: any): number => + sortByMatchRef.current + ? searchSnippetsRef.current[s.sessionId]?.matchedAt || s.lastTimestamp || 0 + : s.lastTimestamp || 0; + merged.sort((a: any, b: any) => key(b) - key(a)); return merged; }; @@ -989,6 +1009,7 @@ function SwitcherApp() { // Drop stale responses (query changed while this one was in flight) if (seq !== deepSearchSeqRef.current || sessionSearchRef2.current !== query) return; deepMatchesRef.current = res?.sessions || []; + searchSnippetsRef.current = res?.snippets || {}; setSearchSnippets(res?.snippets || {}); // Bump a revision instead of filtering here. This callback was created // ~180ms + one IPC round-trip ago and closes over the enrichment maps of @@ -1051,6 +1072,9 @@ function SwitcherApp() { // they change, not on the next keystroke. liveReport, sessionMarks, + // Sorting by match time reads the snippets and the toggle. + searchSnippets, + sortByMatch, ]); const isSearchingSessions = sessionSearchValue.trim().length > 0; @@ -2632,6 +2656,35 @@ function SwitcherApp() { > ? + {/* Issue #146: order a search by when the match happened. Only + while searching — it has no meaning for the timeline. */} + {isSearchingSessions && ( + e.preventDefault()} + onClick={() => { + sortByMatchRef.current = !sortByMatch; + setSortByMatch(!sortByMatch); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + sortByMatchRef.current = !sortByMatch; + setSortByMatch(!sortByMatch); + } + }} + style={sortByMatch ? SCOPE_CHIP_ACTIVE_STYLE : SCOPE_CHIP_STYLE} + > + by match + + )} {/* Scope chips: live processes (issue #94) and saved lists (issue #145). Here, not in the list, because this row has spare width and the list has no spare height. */} @@ -3471,26 +3524,134 @@ function SwitcherApp() { const m = searchSnippets[session.sessionId]; if (!m || !isSearchingSessions) return null; const words = searchHighlightWords; - // Stale guard: snippet must still match the current query - if (!words.some((w) => m.snippet.toLowerCase().includes(w.toLowerCase()))) return null; - const dupFirst = m.promptIndex === 0 && (sessionDisplayMode === 'first' || sessionDisplayMode === 'both'); - const dupLast = m.isLastPrompt && (sessionDisplayMode === 'last' || sessionDisplayMode === 'both'); - if (dupFirst || dupLast) return null; - return ( -
- - - match #{m.promptIndex + 1} - {' '} - - -
- ); + const id = session.sessionId; + const hits = m.hits ?? []; + const k = Math.min(hitIndex[id] ?? 0, Math.max(0, hits.length - 1)); + const hit = hits[k]; + const lineStyle = { overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', marginTop: '1px' } as const; + const stop = (e: React.SyntheticEvent) => e.stopPropagation(); + const lines: React.ReactNode[] = []; + // Issue #146: the matched prompt, with the other hits one + // click away and the prompts around it unfoldable. The + // line is still suppressed when the only hit is already + // on screen (first/last prompt) — vertical space rule. + if (hit && words.some((w) => hit.snippet.toLowerCase().includes(w.toLowerCase()))) { + const lastIndex = (session.messageCount ?? 0) - 1; + const dupFirst = hit.promptIndex === 0 && (sessionDisplayMode === 'first' || sessionDisplayMode === 'both'); + const dupLast = hit.promptIndex === lastIndex && (sessionDisplayMode === 'last' || sessionDisplayMode === 'both'); + const expanded = expandedHits.has(id); + const hasContext = !!(hit.before || hit.after); + if (!(dupFirst || dupLast) || hits.length > 1 || expanded) { + lines.push( +
+ + match #{hit.promptIndex + 1} + {hits.length > 1 && ( + + {' '} + setHitIndex((h) => ({ ...h, [id]: (k - 1 + hits.length) % hits.length }))} + > + ‹ + + {k + 1}/{hits.length} + setHitIndex((h) => ({ ...h, [id]: (k + 1) % hits.length }))} + > + › + + + )} + {hasContext && ( + { + stop(e); + setExpandedHits((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }} + > + {expanded ? '▾' : '▸'} + + )}{' '} + + +
, + ); + if (expanded && hit.before) { + lines.push( +
+ + {' ↑ '} + + +
, + ); + } + if (expanded && hit.after) { + lines.push( +
+ + {' ↓ '} + + +
, + ); + } + } + } + // Issue #141: name the field that matched when that field is + // not on the row — the path, the assistant's mined + // references, a recap the row is not showing, a reply hidden + // behind a recap. Fields that render (title, branch, project + // name, badge, first/last prompt) already carry the highlight. + const hasRecapLine = !!session.__listMember?.recap; + const explain: [string, string][] = []; + for (const r of m.reasons ?? []) { + if (r === 'path') explain.push(['path', session.project || '']); + else if (r === 'assistant') + explain.push([ + 'assistant', + parsedSearch.prRefs.length > 0 + ? parsedSearch.prRefs.map((p) => (p.repo ? `${p.repo}#${p.number}` : `#${p.number}`)).join(' ') + : parsedSearch.words.join(' '), + ]); + else if (r === 'recap' && !hasRecapLine) explain.push(['recap', recaps[id]?.text || '']); + else if (r === 'reply' && hasRecapLine) explain.push(['reply', assistantResponses[id] || '']); + } + for (const [field, text] of explain) { + if (!text) continue; + lines.push( +
+ + match {field}{' '} + + +
, + ); + } + return lines.length > 0 ? <>{lines} : null; })()} {/* Line 3: on a saved-list member, the recap captured with it — Claude Code's own "where we are, what's From 91d7b9299e1a9f3bdcb48e491a1edfa3a579f988 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 01:35:26 +0800 Subject: [PATCH 03/13] feat(sessions): width-aware line caps; memory in both live tooltips --- CHANGELOG.md | 2 +- src/switcher-ui.tsx | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f94a14..cd6ab74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - **`match path` / `match assistant` / `match recap` / `match reply`** lines say which field a row matched in when that field is not on the row (the path, the assistant's mined references, a recap the row is not showing, a reply hidden behind a recap). Fields that render — title, branch, project name, badge, first/last prompt — already carry the highlight, so they add no line: vertical space stays the scarce resource - Feat: switch to a running session by its **terminal (tty)** first, title second ([#142](https://github.com/grimmerk/codev/issues/142) C0). Three `/branch` siblings deliberately share a title, and the title-first match sent every one of their rows to the same iTerm2 tab; a process has exactly one tty, so that is what the click matches now (iTerm2 and Terminal.app; Ghostty has no per-tab tty, [#63](https://github.com/grimmerk/codev/issues/63), and keeps title-then-cwd). Running rows that share a title show their tty (`·ttys003`) so they can be told apart on screen - Feat: a **memory warning chip** beside `● live` when the machine is under pressure — swap past 8GB, or macOS's own pressure level at warn (amber) / critical (red) — with the figures in the live chip's tooltip otherwise. Read from `sysctl vm.swapusage` and `kern.memorystatus_vm_pressure_level` on the same refresh as the process table, so it costs nothing extra. Added the night 42 `claude` processes at 5.1GB pushed a 32GB machine to 18GB of swap: swap was the number that said so first -- Feat: **normal app mode's window can be resized** and reopens at its last position and size (a remembered window that would land on an unplugged display is ignored); the header is already a drag region, so the macOS title-bar double-click action applies to it ([#148](https://github.com/grimmerk/codev/issues/148) step 1; width-aware line caps are step 2) +- Feat: **normal app mode's window can be resized** and reopens at its last position and size (a remembered window that would land on an unplugged display is ignored); the header is already a drag region, so the macOS title-bar double-click action applies to it. **Line caps follow the width**: the character caps on title, messages, branch, reply and recap were tuned for the 800px default and now scale up with the list's measured width (never down), so a wider window shows more of each line rather than more empty space; the Projects list grows with the window instead of stopping at 480px with a scrollbar mid-window ([#148](https://github.com/grimmerk/codev/issues/148), both steps) - Fix: a session parked at Claude Code's context-limit prompt no longer shows as `working` forever — no hook fires there, so a `working` status untouched for 10 minutes is shown as idle ([#110](https://github.com/grimmerk/codev/issues/110)) - Docs: README on keeping the machine responsive — sessions grow with time, Spotlight should skip `~/Library/Application Support/Claude` and `~/.claude`, and what the swap chip means diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index c61481c..539213a 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -751,6 +751,20 @@ function SwitcherApp() { const [openingList, setOpeningList] = useState(false); // The query-language cheat sheet under the search box (issue #140). const [searchHelpOpen, setSearchHelpOpen] = useState(false); + // Width of the sessions list, for width-aware line caps (issue #148). + // 0 until measured, which the cap scale treats as the default width. + const sessionListRef = useRef(null); + const [sessionListWidth, setSessionListWidth] = useState(0); + useEffect(() => { + const el = sessionListRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const ro = new ResizeObserver((entries) => { + const w = Math.round(entries[0]?.contentRect.width ?? 0); + setSessionListWidth((prev) => (Math.abs(prev - w) > 8 ? w : prev)); + }); + ro.observe(el); + return () => ro.disconnect(); + }); const [listsNotice, setListsNotice] = useState(null); // Set when the lists store exists but cannot be trusted as written — a // file an earlier build wrote, or hand-edited. Persistent until repaired @@ -1085,10 +1099,17 @@ function SwitcherApp() { // One rule for every length-capped line in a row: while searching, the window // moves to the first match so you can see WHY the row is in the results; // otherwise it keeps both ends, because these titles put the newest step last. - const fitToRow = (text: string, max: number) => - isSearchingSessions - ? windowAroundMatch(text, searchWordsLower, max) - : truncateMiddle(text, max); + // Issue #148 step 2: the caps are in characters, tuned for the default + // 800px window. A wider window scales them up (never down — the constants + // stay the floor), measured off the list container, so a resize actually + // shows more of each line instead of more empty space to its right. + const capScale = Math.max(1, sessionListWidth / 760); + const fitToRow = (text: string, max: number) => { + const cap = Math.round(max * capScale); + return isSearchingSessions + ? windowAroundMatch(text, searchWordsLower, cap) + : truncateMiddle(text, cap); + }; const hiddenSet = new Set(sessionMarks.hidden); const hasPins = Object.keys(sessionMarks.pins).length > 0; const viewingList = @@ -2695,7 +2716,7 @@ function SwitcherApp() { title={ liveOnlyActive ? `Show every session again${liveReport ? ` · measured ${formatRelativeTime(liveReport.measuredAt)}; re-read on each open and on toggling${memorySummary ? ` · ${memorySummary}` : ''}` : ''}` - : `Show only sessions with a running process${staleCount ? ` · ${staleCount} stale registration${staleCount > 1 ? 's' : ''} in ~/.claude/sessions` : ''}` + : `Show only sessions with a running process${staleCount ? ` · ${staleCount} stale registration${staleCount > 1 ? 's' : ''} in ~/.claude/sessions` : ''}${memorySummary ? ` · ${memorySummary}` : ''}` } onMouseDown={(e) => e.preventDefault()} onClick={toggleLiveOnly} @@ -2877,7 +2898,7 @@ function SwitcherApp() { ⚠ {listsNotice} )} -
+
{/* A list being viewed: its header replaces every other zone. */} {listViewActive && viewingList && (
@@ -3977,7 +3998,10 @@ function SwitcherApp() { backgroundColor: 'transparent', padding: '0 6px', margin: '0 6px', - maxHeight: '480px', // Increased max height for more items + // Grows with the window now that normal mode is resizable (#148); + // the old fixed 480px left a scrollbar floating mid-window. + maxHeight: 'calc(100vh - 150px)', + overflowX: 'hidden', }), option: (base) => ({ ...base, From 945064d02ff10457ecfe752131414071c76fcdec Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 01:53:38 +0800 Subject: [PATCH 04/13] fix(window): menu-bar mode keeps its default size and centring --- src/main.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index c10915e..8451212 100644 --- a/src/main.ts +++ b/src/main.ts @@ -136,9 +136,15 @@ const showSwitcherWindow = () => { } if (appMode === 'menubar') { - // Menu bar mode: always center on screen + // Menu bar mode: always the default size, centred, not resizable — the + // window may have been created (and resized, and remembered) in normal + // mode, so this is enforced at every show rather than at creation. + window.setResizable(false); + window.setSize(WIN_WIDTH, WIN_HEIGHT, false); const position = getWindowPosition(); window.setPosition(position.x, position.y, false); + } else { + window.setResizable(true); } if (window.isMinimized()) { window.restore(); @@ -484,7 +490,8 @@ const createSwitcherWindow = (initialMode?: string): BrowserWindow => { typeof b.y !== 'number' || typeof b.width !== 'number' || typeof b.height !== 'number' || - window.isDestroyed() + window.isDestroyed() || + appMode !== 'normal' ) { return; } @@ -502,7 +509,9 @@ const createSwitcherWindow = (initialMode?: string): BrowserWindow => { const saveBounds = () => { if (saveBoundsTimer) clearTimeout(saveBoundsTimer); saveBoundsTimer = setTimeout(() => { - if (window.isDestroyed()) return; + // Checked when the timer fires, not when the handler was attached: + // the mode can change under a window that stays alive. + if (window.isDestroyed() || appMode !== 'normal') return; const b = window.getBounds(); settings .set('switcher-window-bounds', { x: b.x, y: b.y, width: b.width, height: b.height }) From 080acec3ee0497aa77a1d0ba830d99c5143adebb Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 02:05:37 +0800 Subject: [PATCH 05/13] feat(sessions): match snippet follows width; context toggle labelled --- src/session-search.ts | 9 ++++++--- src/switcher-ui.tsx | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/session-search.ts b/src/session-search.ts index ae1b4b8..6bd45e5 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -554,6 +554,7 @@ export const findPromptMatch = ( wordsLower: string[], prRefs: PrRef[] = [], repos?: string[], + radius = 40, ): PromptMatch | null => { for (let i = 0; i < prompts.length; i++) { const lower = prompts[i].toLowerCase(); @@ -562,7 +563,7 @@ export const findPromptMatch = ( if (idx !== -1) { return { promptIndex: i, - snippet: extractSnippet(prompts[i], idx, w.length), + snippet: extractSnippet(prompts[i], idx, w.length, radius), }; } } @@ -571,7 +572,7 @@ export const findPromptMatch = ( if (hit) { return { promptIndex: i, - snippet: extractSnippet(prompts[i], hit.index, hit.length), + snippet: extractSnippet(prompts[i], hit.index, hit.length, radius), }; } } @@ -613,7 +614,9 @@ export const findPromptHits = ( ): PromptHit[] => { const hits: PromptHit[] = []; for (let i = 0; i < prompts.length && hits.length < limit; i++) { - const one = findPromptMatch([prompts[i]], wordsLower, prRefs, repos); + // A wide snippet: the row caps it to its width (#148), so a wider + // window shows more of the sentence rather than the same 80 characters. + const one = findPromptMatch([prompts[i]], wordsLower, prRefs, repos, 160); if (!one) continue; hits.push({ promptIndex: i, diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 539213a..5681125 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -3608,13 +3608,13 @@ function SwitcherApp() { }); }} > - {expanded ? '▾' : '▸'} + {expanded ? '▾ hide' : '▸ context'} )}{' '} From ef0e415ac6b8a78e264223d385edf55e3660c024 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 02:17:30 +0800 Subject: [PATCH 06/13] feat(window): reset-size button in the header (normal mode) --- CHANGELOG.md | 2 +- src/electron-api.d.ts | 2 ++ src/main.ts | 11 +++++++++++ src/preload.ts | 1 + src/switcher-ui.tsx | 19 +++++++++++++++++++ 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd6ab74..5728ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - **`match path` / `match assistant` / `match recap` / `match reply`** lines say which field a row matched in when that field is not on the row (the path, the assistant's mined references, a recap the row is not showing, a reply hidden behind a recap). Fields that render — title, branch, project name, badge, first/last prompt — already carry the highlight, so they add no line: vertical space stays the scarce resource - Feat: switch to a running session by its **terminal (tty)** first, title second ([#142](https://github.com/grimmerk/codev/issues/142) C0). Three `/branch` siblings deliberately share a title, and the title-first match sent every one of their rows to the same iTerm2 tab; a process has exactly one tty, so that is what the click matches now (iTerm2 and Terminal.app; Ghostty has no per-tab tty, [#63](https://github.com/grimmerk/codev/issues/63), and keeps title-then-cwd). Running rows that share a title show their tty (`·ttys003`) so they can be told apart on screen - Feat: a **memory warning chip** beside `● live` when the machine is under pressure — swap past 8GB, or macOS's own pressure level at warn (amber) / critical (red) — with the figures in the live chip's tooltip otherwise. Read from `sysctl vm.swapusage` and `kern.memorystatus_vm_pressure_level` on the same refresh as the process table, so it costs nothing extra. Added the night 42 `claude` processes at 5.1GB pushed a 32GB machine to 18GB of swap: swap was the number that said so first -- Feat: **normal app mode's window can be resized** and reopens at its last position and size (a remembered window that would land on an unplugged display is ignored); the header is already a drag region, so the macOS title-bar double-click action applies to it. **Line caps follow the width**: the character caps on title, messages, branch, reply and recap were tuned for the 800px default and now scale up with the list's measured width (never down), so a wider window shows more of each line rather than more empty space; the Projects list grows with the window instead of stopping at 480px with a scrollbar mid-window ([#148](https://github.com/grimmerk/codev/issues/148), both steps) +- Feat: **normal app mode's window can be resized** and reopens at its last position and size (a remembered window that would land on an unplugged display is ignored); the header is already a drag region, so the macOS title-bar double-click action applies to it. **Line caps follow the width**: the character caps on title, messages, branch, reply and recap were tuned for the 800px default and now scale up with the list's measured width (never down), so a wider window shows more of each line rather than more empty space; the Projects list grows with the window instead of stopping at 480px with a scrollbar mid-window; a `⤢` beside the shortcut label in the header (normal mode only) resets the window to its default size and position, since macOS zoom only restores the previous user size ([#148](https://github.com/grimmerk/codev/issues/148), both steps). Menu-bar mode is re-asserted at every show — default size, centred, not resizable — so a window resized in normal mode does not carry its size across a mode switch - Fix: a session parked at Claude Code's context-limit prompt no longer shows as `working` forever — no hook fires there, so a `working` status untouched for 10 minutes is shown as idle ([#110](https://github.com/grimmerk/codev/issues/110)) - Docs: README on keeping the machine responsive — sessions grow with time, Spotlight should skip `~/Library/Application Support/Claude` and `~/.claude`, and what the swap chip means diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 0808a2d..2ea23bf 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -134,6 +134,8 @@ interface IElectronAPI { // Session terminal settings getSessionTerminalApp: () => Promise; + /** Normal app mode: back to the default size, centred, and forget the remembered bounds (#148). */ + resetSwitcherWindowBounds: () => Promise; setSessionTerminalApp: (app: string) => void; getSessionTerminalMode: () => Promise; setSessionTerminalMode: (mode: string) => void; diff --git a/src/main.ts b/src/main.ts index 8451212..6a01e14 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2384,6 +2384,17 @@ ipcMain.on('set-app-mode', async (_event, mode: string) => { } }); +// Normal app mode: the default size, centred, and the remembered bounds +// forgotten — macOS has no "reset" convention (zoom restores the previous +// user size), so the header offers one (#148). +ipcMain.handle('reset-switcher-window-bounds', async () => { + const window = getSwitcherWindow(); + if (!window) return; + const position = getWindowPosition(); + window.setBounds({ x: position.x, y: position.y, width: WIN_WIDTH, height: WIN_HEIGHT }, false); + await settings.unset('switcher-window-bounds').catch(() => {}); +}); + ipcMain.handle('get-session-terminal-app', async () => { return (await settings.get('session-terminal-app')) || 'iterm2'; }); diff --git a/src/preload.ts b/src/preload.ts index 235f64b..a75e222 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -60,6 +60,7 @@ contextBridge.exposeInMainWorld('electronAPI', { onAppModeChanged: (callback: any) => ipcRenderer.on('app-mode-changed', callback), onShortcutsUpdated: (callback: any) => ipcRenderer.on('shortcuts-updated', callback), getSessionTerminalApp: () => ipcRenderer.invoke('get-session-terminal-app'), + resetSwitcherWindowBounds: () => ipcRenderer.invoke('reset-switcher-window-bounds'), setSessionTerminalApp: (app: string) => ipcRenderer.send('set-session-terminal-app', app), getSessionTerminalMode: () => ipcRenderer.invoke('get-session-terminal-mode'), setSessionTerminalMode: (mode: string) => ipcRenderer.send('set-session-terminal-mode', mode), diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 5681125..15ee79b 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -2437,6 +2437,25 @@ function SwitcherApp() { {quickSwitcherShortcut} )} + {currentAppMode === 'normal' && ( + window.electronAPI.resetSwitcherWindowBounds()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + window.electronAPI.resetSwitcherWindowBounds(); + } + }} + style={{ fontSize: '11px', color: '#555', cursor: 'pointer' }} + onMouseEnter={(e) => { e.currentTarget.style.color = '#888'; }} + onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; }} + > + ⤢ + + )}
Date: Sun, 6 Sep 2026 02:24:32 +0800 Subject: [PATCH 07/13] fix(ui): draw tooltips ourselves; native title never showed unfocused --- CHANGELOG.md | 1 + src/switcher-ui.tsx | 137 +++++++++++++++++++++++++++++++++----------- 2 files changed, 103 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5728ab0..70211ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Feat: switch to a running session by its **terminal (tty)** first, title second ([#142](https://github.com/grimmerk/codev/issues/142) C0). Three `/branch` siblings deliberately share a title, and the title-first match sent every one of their rows to the same iTerm2 tab; a process has exactly one tty, so that is what the click matches now (iTerm2 and Terminal.app; Ghostty has no per-tab tty, [#63](https://github.com/grimmerk/codev/issues/63), and keeps title-then-cwd). Running rows that share a title show their tty (`·ttys003`) so they can be told apart on screen - Feat: a **memory warning chip** beside `● live` when the machine is under pressure — swap past 8GB, or macOS's own pressure level at warn (amber) / critical (red) — with the figures in the live chip's tooltip otherwise. Read from `sysctl vm.swapusage` and `kern.memorystatus_vm_pressure_level` on the same refresh as the process table, so it costs nothing extra. Added the night 42 `claude` processes at 5.1GB pushed a 32GB machine to 18GB of swap: swap was the number that said so first - Feat: **normal app mode's window can be resized** and reopens at its last position and size (a remembered window that would land on an unplugged display is ignored); the header is already a drag region, so the macOS title-bar double-click action applies to it. **Line caps follow the width**: the character caps on title, messages, branch, reply and recap were tuned for the 800px default and now scale up with the list's measured width (never down), so a wider window shows more of each line rather than more empty space; the Projects list grows with the window instead of stopping at 480px with a scrollbar mid-window; a `⤢` beside the shortcut label in the header (normal mode only) resets the window to its default size and position, since macOS zoom only restores the previous user size ([#148](https://github.com/grimmerk/codev/issues/148), both steps). Menu-bar mode is re-asserted at every show — default size, centred, not resizable — so a window resized in normal mode does not carry its size across a mode switch +- Fix: **tooltips now show.** Every hint in the switcher (chips, badges, controls, the per-process figures) is drawn by the app instead of relying on the native `title` tooltip, which Chromium paints only while the window is the key window — a frameless popup the pointer merely crosses usually is not, so the hints almost never appeared - Fix: a session parked at Claude Code's context-limit prompt no longer shows as `working` forever — no hook fires there, so a `working` status untouched for 10 minutes is shown as idle ([#110](https://github.com/grimmerk/codev/issues/110)) - Docs: README on keeping the machine responsive — sessions grow with time, Spotlight should skip `~/Library/Application Support/Claude` and `~/.claude`, and what the swap chip means diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 15ee79b..dc5cfa0 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -571,6 +571,68 @@ const nextListName = (existing: string[]): string => { /** Caution it will be invoked twice due to !! */ let loadTimes = 0; +/** + * One tooltip for the whole window, drawn by us. Native `title` tooltips are + * painted by Chromium only while the window is the key window, and a + * frameless popup that the pointer merely crosses usually is not — measured + * in the first live test as "the tooltip almost never shows". Elements carry + * `data-tip` instead; this layer reads it on hover and positions a box under + * the element, clamped to the viewport. + */ +function TooltipLayer() { + const [tip, setTip] = useState<{ text: string; x: number; y: number } | null>(null); + useEffect(() => { + const onOver = (e: MouseEvent) => { + const el = (e.target as HTMLElement | null)?.closest?.('[data-tip]') as HTMLElement | null; + const text = el?.getAttribute('data-tip'); + if (!el || !text) { + setTip(null); + return; + } + const r = el.getBoundingClientRect(); + setTip({ text, x: r.left, y: r.bottom + 6 }); + }; + const onLeave = () => setTip(null); + document.addEventListener('mouseover', onOver); + document.addEventListener('mousedown', onLeave); + document.addEventListener('mouseleave', onLeave); + window.addEventListener('blur', onLeave); + return () => { + document.removeEventListener('mouseover', onOver); + document.removeEventListener('mousedown', onLeave); + document.removeEventListener('mouseleave', onLeave); + window.removeEventListener('blur', onLeave); + }; + }, []); + if (!tip) return null; + const maxWidth = 360; + const left = Math.max(8, Math.min(tip.x, window.innerWidth - maxWidth - 8)); + return ( +
+ {tip.text} +
+ ); +} + function SwitcherApp() { const optionPress = useRef(false); const launchClaudeRef = useRef<'external' | 'codev' | 'external-pick' | null>(null); @@ -2429,7 +2491,7 @@ function SwitcherApp() { {quickSwitcherShortcut && ( setSettingsOpenToTab('shortcuts')} - title="Click to customize shortcuts" + data-tip="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'; }} @@ -2441,7 +2503,7 @@ function SwitcherApp() { window.electronAPI.resetSwitcherWindowBounds()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -2678,7 +2740,7 @@ function SwitcherApp() { role="button" tabIndex={0} aria-pressed={searchHelpOpen} - title={searchHelpOpen ? 'Hide search syntax' : 'Search syntax: title: branch: msg: has:pr is:live after:7d #147 …'} + data-tip={searchHelpOpen ? 'Hide search syntax' : 'Search syntax: title: branch: msg: has:pr is:live after:7d #147 …'} onMouseDown={(e) => e.preventDefault()} onClick={() => setSearchHelpOpen((v) => !v)} onKeyDown={(e) => { @@ -2703,7 +2765,7 @@ function SwitcherApp() { role="button" tabIndex={0} aria-pressed={sortByMatch} - title={ + data-tip={ sortByMatch ? 'Ordered by when the match happened; click for last activity' : 'Order by when the match happened instead of the session’s last activity' @@ -2732,7 +2794,7 @@ function SwitcherApp() { role="button" tabIndex={0} aria-pressed={liveOnlyActive} - title={ + data-tip={ liveOnlyActive ? `Show every session again${liveReport ? ` · measured ${formatRelativeTime(liveReport.measuredAt)}; re-read on each open and on toggling${memorySummary ? ` · ${memorySummary}` : ''}` : ''}` : `Show only sessions with a running process${staleCount ? ` · ${staleCount} stale registration${staleCount > 1 ? 's' : ''} in ~/.claude/sessions` : ''}${memorySummary ? ` · ${memorySummary}` : ''}` @@ -2755,7 +2817,7 @@ function SwitcherApp() { in the live chip's tooltip. */} {memoryWarning && ( e.preventDefault()} onClick={(e) => { // The document-level click handler refocuses the search box @@ -2816,7 +2878,7 @@ function SwitcherApp() { role="button" tabIndex={0} aria-pressed={listsExpanded} - title={ + data-tip={ sessionLists.length === 0 ? 'No saved lists yet — scope the list (live / only / search) and click "save list…"' : listsExpanded @@ -2921,13 +2983,13 @@ function SwitcherApp() { {/* A list being viewed: its header replaces every other zone. */} {listViewActive && viewingList && (
- + 🗂 {viewingList.name} ({viewingList.members.length}) · saved {formatRelativeTime(viewingList.createdAt)} {' '} e.preventDefault()} onClick={(e) => { e.stopPropagation(); @@ -2976,7 +3038,7 @@ function SwitcherApp() { role="button" tabIndex={idle ? -1 : 0} aria-disabled={idle} - title={ + data-tip={ n === 0 ? 'Every member of this list has a running process' : armed @@ -3010,7 +3072,7 @@ function SwitcherApp() { e.preventDefault()} onClick={closeList} onKeyDown={(e) => { @@ -3053,7 +3115,7 @@ function SwitcherApp() { role="button" tabIndex={0} aria-label={`Open list ${l.name}, ${l.members.length} sessions`} - title={`${l.members.length} sessions · saved ${new Date(l.createdAt).toLocaleString()} · click or Enter to view`} + data-tip={`${l.members.length} sessions · saved ${new Date(l.createdAt).toLocaleString()} · click or Enter to view`} onMouseDown={(e) => e.preventDefault()} onClick={() => openList(l.id)} onKeyDown={(e) => { @@ -3076,7 +3138,7 @@ function SwitcherApp() { role="button" tabIndex={0} aria-label={`Rename list ${l.name}`} - title="Rename this list" + data-tip="Rename this list" onMouseDown={(e) => e.preventDefault()} onClick={(e) => { e.stopPropagation(); @@ -3097,7 +3159,7 @@ function SwitcherApp() { role="button" tabIndex={0} aria-label={`Delete list ${l.name}`} - title={confirmDeleteListId === l.id ? 'Click again to delete this list' : 'Delete this list'} + data-tip={confirmDeleteListId === l.id ? 'Click again to delete this list' : 'Delete this list'} onMouseDown={(e) => e.preventDefault()} onClick={(e) => { e.stopPropagation(); @@ -3130,7 +3192,7 @@ function SwitcherApp() { )} {hiddenSet.has(session.sessionId) && ( - + )} {' '}·{tag} @@ -3349,7 +3411,7 @@ function SwitcherApp() { {index === selectedSessionIndex && !session.__liveOrphan && ( <> e.preventDefault()} onClick={(e) => { e.stopPropagation(); togglePin(session); }} style={{ cursor: 'pointer', fontSize: '11px', color: sessionMarks.pins[session.sessionId] ? '#f5b942' : '#777' }} @@ -3357,7 +3419,7 @@ function SwitcherApp() { 📌 e.preventDefault()} onClick={(e) => { e.stopPropagation(); toggleHide(session); }} style={{ cursor: 'pointer', fontSize: '11px', color: hiddenSet.has(session.sessionId) ? '#e07a5f' : '#666' }} @@ -3386,7 +3448,7 @@ function SwitcherApp() { {liveStats && ( {formatMb(live.rssKb)} · {formatUptime(live.uptimeSec)} @@ -3394,7 +3456,7 @@ function SwitcherApp() { {!live.registered && ( ⚠ unregistered @@ -3402,7 +3464,7 @@ function SwitcherApp() { {session.__liveExtra && ( ⚠ 2nd process @@ -3426,7 +3488,7 @@ function SwitcherApp() { padding: '1px 5px', fontFamily: 'Menlo, monospace', }} - title={session.sessionId} + data-tip={session.sessionId} > id {session.sessionId.slice(0, 8)} @@ -3452,7 +3514,7 @@ function SwitcherApp() { cursor: 'pointer', backgroundColor: urlMatch ? 'rgba(126, 200, 227, 0.2)' : 'transparent', }} - title={prInfo.prUrl} + data-tip={prInfo.prUrl} onClick={(e) => { e.stopPropagation(); window.electronAPI.openExternal(prInfo.prUrl); @@ -3477,7 +3539,7 @@ function SwitcherApp() { padding: '1px 4px', textTransform: 'uppercase', }} - title={`Claude account: ${session.accountLabel}`} + data-tip={`Claude account: ${session.accountLabel}`} > {session.accountLabel} @@ -3592,7 +3654,7 @@ function SwitcherApp() { setHitIndex((h) => ({ ...h, [id]: (k - 1 + hits.length) % hits.length }))} > @@ -3602,7 +3664,7 @@ function SwitcherApp() { setHitIndex((h) => ({ ...h, [id]: (k + 1) % hits.length }))} > @@ -3614,7 +3676,7 @@ function SwitcherApp() { { @@ -3712,7 +3774,7 @@ function SwitcherApp() { null, DropdownIndicator: () => (
{isMultiAccountUI @@ -4197,7 +4259,12 @@ export default SwitcherApp; // Initialize the app document.addEventListener('DOMContentLoaded', () => { const root = ReactDOM.createRoot(document.getElementById('switcher-root')); - root.render(); + root.render( + <> + + + , + ); console.log('SwitcherApp rendered'); }); From a73bd5174c77a57036426bc421cc96802afcdadc Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 02:42:02 +0800 Subject: [PATCH 08/13] docs: switch order is tty-first; titles refresh on open --- README.md | 2 +- docs/claude-session-integration-design.md | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d3fee22..2653bbb 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Two chips beside the search box, on the [Session Buddy](https://sessionbuddy.com | `save list…` | Appears whenever the list is scoped (`● live`, `only`, or a search): **saves exactly what is on screen as a named list**. The default name is today's `MMDD`, then `MMDD-2`, `MMDD-3` — a label, not an identity. | | `🗂 N` | Shows the saved lists. Click one to view its members **in the order they were captured** and resume any of them; `✎` renames, `✕` (then `delete?`) deletes. | -A saved member stores what you recognise a session by — title, branch, pin state at capture, the last messages, and the **recap** line Claude Code writes into the transcript (the `※ recap:` "where we are, what's next" line), which replaces the last-reply line on the member's row; a recap much older than the session's last activity is marked `⏱`, since its "next step" may already be done. A member whose transcript is gone still reads as the session it was. Opening a session from a list or from the live scope **leaves you in that scope** when you come back (only the search box is cleared), so you can work through a set one session at a time. A list's header also carries **`▶ open N`** — resume the N members that are **not** running, for the moments that call for the whole set: after a reboot or a macOS update, after closing everything to reclaim memory, or to move the set to another terminal app (change the terminal in Settings, then press it). It asks once, showing the projected cost (`open 12 · ~1.7GB?`, from the mean size of the processes currently running), opens only what is not already running (so pressing it twice opens nothing new), launches one session every 0.7s rather than all at once, and reports members it skipped because their project folder or transcript is gone. Lists live in `~/.config/codev/session-lists.json`; a file that cannot be trusted as written is reported at load, never rewritten. +A saved member stores what you recognise a session by — title, branch, pin state at capture, the last messages, and the **recap** line Claude Code writes into the transcript (the `※ recap:` "where we are, what's next" line), which replaces the last-reply line on the member's row; a recap much older than the session's last activity is marked `⏱`, since its "next step" may already be done. A member whose transcript is gone still reads as the session it was. Opening a session from a list or from the live scope **leaves you in that scope** when you come back (only the search box is cleared), so you can work through a set one session at a time. A list's header also carries **`▶ open N`** — resume the N members that are **not** running, for the moments that call for the whole set: after a reboot or a macOS update, after closing everything to reclaim memory, or to move the set to another terminal app (change the terminal in Settings, then press it). It asks once, showing the projected cost (`open 12 · ~1.7GB?`, from the mean size of the processes currently running), opens only what is not already running (so pressing it twice opens nothing new), launches one session every 0.7s rather than all at once, and reports members it skipped because their project folder or transcript is gone. Lists live in `~/.config/codev/session-lists.json`; a file that cannot be trusted as written is reported at load, never rewritten. Titles, branches and recaps are re-read from the transcripts when the popup is opened (or the Sessions tab is entered), not while it stays open — a `/rename` in the terminal shows up on the next open. **Simple rule**: when running multiple sessions in the same project directory at the same time, give each running session a name. Closed sessions don't need names — they won't cause issues. diff --git a/docs/claude-session-integration-design.md b/docs/claude-session-integration-design.md index bad7e42..69709a0 100644 --- a/docs/claude-session-integration-design.md +++ b/docs/claude-session-integration-design.md @@ -277,13 +277,15 @@ Detection Flow: | Action | Method | |--------|--------| | **Detect** | `ps aux` → extract `--resume ` from args, or `lsof` for cwd | -| **Switch** | Three-layer AppleScript matching: (1) title match → (2) TTY fallback → (3) not found | +| **Switch** | Three-layer AppleScript matching: (1) TTY match → (2) title fallback → (3) not found (order flipped in PR #152, see below) | | **Launch (tab)** | AppleScript: `create tab with default profile` + `write text` | | **Launch (window)** | AppleScript: `create window with default profile` + `write text` | -**Switch matching order (title first for same-cwd accuracy):** -1. **Title match** — if session has `/rename` custom title, match against iTerm2 tab `name of s contains "title"`. Most precise for same-cwd sessions. -2. **TTY match** — match process TTY against iTerm2 session TTYs. Precise when PID-session mapping is correct. +**Switch matching order (TTY first since PR #152; title first before it):** title-first was a 2026-03-21 workaround (`868db59`) for the era when a same-cwd session's pid was *guessed* from its cwd, so the wrong pid's tty could pick the wrong tab. Since PR #147 the pid comes from Claude Code's own `~/.claude/sessions/.json` registration validated against `ps` (and from `--resume ` args), so it is exact — and titles are the thing that is NOT unique (three `/branch` siblings under 2.1.260 shared one; a manual rename or one session opened twice still does). Remaining gap: an *unregistered* process still gets a guessed pid, where title-first was safer — the order should follow pid provenance (registered → tty first; guessed → title first); not done yet. +1. **TTY match** — match the process TTY against iTerm2 session TTYs. Exact when the pid is exact (registration + `ps` join). +2. **Title match** — fallback: if the session has a `/rename` custom title, match `name of s contains "title"`. Not unique across same-named sessions. + +**Where TTY is relied on** (the list to revisit when Ghostty exposes a per-tab tty, #63): the iTerm2 and Terminal.app switch scripts above, `detectTerminalApp` (parent-process walk), the live view's `·ttysNNN` tag for same-titled rows (`live-sessions.ts`, PR #152), and `openSessionInGhostty`, which today has only title → cwd and would gain the same TTY-first layer. 3. **Not found** — activates iTerm2 without switching. **Workarounds discovered:** @@ -295,7 +297,7 @@ Detection Flow: | Action | Method | |--------|--------| | **Detect** | Process tree walk → `commLower === 'terminal'` or `commLower.includes('terminal.app')` | -| **Switch** | Two-layer AppleScript matching: (1) title match → (2) TTY fallback | +| **Switch** | Two-layer AppleScript matching: (1) TTY match → (2) title fallback (PR #152) | | **Launch (tab)** | AppleScript: `do script "cmd" in front window` | | **Launch (window)** | AppleScript: `do script "cmd"` (standalone) | From 7f526c3f16b02c710570542c3a48b14ce6e1aecf Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 02:45:12 +0800 Subject: [PATCH 09/13] fix(projects): list reaches the window bottom again --- src/switcher-ui.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index dc5cfa0..3a87c26 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -4081,7 +4081,7 @@ function SwitcherApp() { margin: '0 6px', // Grows with the window now that normal mode is resizable (#148); // the old fixed 480px left a scrollbar floating mid-window. - maxHeight: 'calc(100vh - 150px)', + maxHeight: 'calc(100vh - 120px)', overflowX: 'hidden', }), option: (base) => ({ From 86e801dc443597474a2676211a6c869d0d313730 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 03:08:23 +0800 Subject: [PATCH 10/13] feat(switch): tty/title order by pid provenance; review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch (issue #142 C0): the iTerm2 / Terminal.app AppleScript now tries tty first only when the pid came from a registration whose session the history knows; a pid the detection guessed (same-cwd match, tab-title cross-ref, legacy ps/lsof) keeps the old title-first order. One builder for both terminals (src/terminal-switch.ts), classification via isGuessedPid. Design doc gains three flow diagrams (list + detection, open, switch — every terminal) and the order's history; README table and CHANGELOG updated. Review round 1 (CodeRabbit + cubic): - explainMatch names the field a scoped term (title:/recap:/msg:) hit - context cap slices by code point (no split surrogate pair) - dupLast judged against the prompt index (promptCount), not messageCount - dots re-derived once a minute so a stale `working` goes idle on time - hit controls and the header reset are keyboard-operable, aria-labelled; the reset IPC rejection is caught - window bounds: save/restore wired for mode switches, the restore awaited before the first show, reset no longer re-saved by the debounced write, centred on the work area's origin - MemoryPressure.level doc comment matches the null contract - reasons typed as MatchField[] in the IPC surface Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- README.md | 4 +- docs/claude-session-integration-design.md | 103 ++++++++++- src/claude-session-utility.ts | 136 ++++++-------- src/electron-api.d.ts | 6 +- src/live-sessions.ts | 6 +- src/main.ts | 209 ++++++++++++++-------- src/preload.ts | 3 +- src/session-search.test.ts | 27 +++ src/session-search.ts | 19 +- src/switcher-ui.tsx | 113 ++++++++---- src/terminal-switch.test.ts | 56 ++++++ src/terminal-switch.ts | 139 ++++++++++++++ 13 files changed, 617 insertions(+), 206 deletions(-) create mode 100644 src/terminal-switch.test.ts create mode 100644 src/terminal-switch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 70211ae..d500266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - **The prompts around a hit**, one click (`▸`) away on the match line: the prompt before (`↑`) and after (`↓`). The smallest useful version of the reader in #66 — user prompts only; assistant text is not in the index - **`by match`** chip while searching: order results by when the match happened instead of the session's last activity, so the session where you typed the word an hour ago is not buried under one touched five minutes ago. Off by default — "what was I just working on" is the commoner question - **`match path` / `match assistant` / `match recap` / `match reply`** lines say which field a row matched in when that field is not on the row (the path, the assistant's mined references, a recap the row is not showing, a reply hidden behind a recap). Fields that render — title, branch, project name, badge, first/last prompt — already carry the highlight, so they add no line: vertical space stays the scarce resource -- Feat: switch to a running session by its **terminal (tty)** first, title second ([#142](https://github.com/grimmerk/codev/issues/142) C0). Three `/branch` siblings deliberately share a title, and the title-first match sent every one of their rows to the same iTerm2 tab; a process has exactly one tty, so that is what the click matches now (iTerm2 and Terminal.app; Ghostty has no per-tab tty, [#63](https://github.com/grimmerk/codev/issues/63), and keeps title-then-cwd). Running rows that share a title show their tty (`·ttys003`) so they can be told apart on screen +- Feat: switch to a running session by its **terminal (tty)** first, title second ([#142](https://github.com/grimmerk/codev/issues/142) C0). Three `/branch` siblings deliberately share a title, and the title-first match sent every one of their rows to the same iTerm2 tab; a process has exactly one tty, so that is what the click matches now (iTerm2 and Terminal.app; Ghostty has no per-tab tty, [#63](https://github.com/grimmerk/codev/issues/63), and keeps title-then-cwd). Running rows that share a title show their tty (`·ttys003`) so they can be told apart on screen. **The order follows where the pid came from**: a pid read from Claude Code's own registration file is exact and goes tty-first; a pid the detection had to *guess* (a same-cwd match or a terminal-tab title, for a process whose registration names a session the history does not know yet) keeps the old title-first order, because a guessed tty can point at a sibling — the case that made title-first the safe choice in 2026-03. Both keys are always tried; only the order changes (`src/terminal-switch.ts`, flow diagram in `docs/claude-session-integration-design.md`) - Feat: a **memory warning chip** beside `● live` when the machine is under pressure — swap past 8GB, or macOS's own pressure level at warn (amber) / critical (red) — with the figures in the live chip's tooltip otherwise. Read from `sysctl vm.swapusage` and `kern.memorystatus_vm_pressure_level` on the same refresh as the process table, so it costs nothing extra. Added the night 42 `claude` processes at 5.1GB pushed a 32GB machine to 18GB of swap: swap was the number that said so first - Feat: **normal app mode's window can be resized** and reopens at its last position and size (a remembered window that would land on an unplugged display is ignored); the header is already a drag region, so the macOS title-bar double-click action applies to it. **Line caps follow the width**: the character caps on title, messages, branch, reply and recap were tuned for the 800px default and now scale up with the list's measured width (never down), so a wider window shows more of each line rather than more empty space; the Projects list grows with the window instead of stopping at 480px with a scrollbar mid-window; a `⤢` beside the shortcut label in the header (normal mode only) resets the window to its default size and position, since macOS zoom only restores the previous user size ([#148](https://github.com/grimmerk/codev/issues/148), both steps). Menu-bar mode is re-asserted at every show — default size, centred, not resizable — so a window resized in normal mode does not carry its size across a mode switch - Fix: **tooltips now show.** Every hint in the switcher (chips, badges, controls, the per-process figures) is drawn by the app instead of relying on the native `title` tooltip, which Chromium paints only while the window is the key window — a frameless popup the pointer merely crosses usually is not, so the hints almost never appeared diff --git a/README.md b/README.md index 2653bbb..094af4e 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ For the full same-cwd accuracy matrix (detection + switch by launch method and t | Terminal | Switch method | Launch method | Notes | |----------|--------------|---------------|-------| -| iTerm2 | Title match → TTY fallback | AppleScript new tab/window | Most reliable; cross-reference fixes detection for bare `claude` + `/rename`'d sessions | -| Terminal.app | Title match → TTY fallback | AppleScript `do script` | Built-in macOS terminal; same TTY accuracy as iTerm2 | +| iTerm2 | TTY match → title fallback when the pid is registered (exact); title match → TTY fallback when it was guessed | AppleScript new tab/window | Most reliable; cross-reference fixes detection for bare `claude` + `/rename`'d sessions | +| Terminal.app | Same order rule as iTerm2 | AppleScript `do script` | Built-in macOS terminal; same TTY accuracy as iTerm2 | | Ghostty | Title match → cwd fallback | AppleScript new tab/window | Needs `/rename` for same-cwd. **Note:** Ghostty may not support `⌘+V` (paste) and `⌘+Z` (undo) in CodeV's search bar by default — add `keybind = super+v=paste_from_clipboard` and `keybind = super+z=undo` to `~/.config/ghostty/config` ([ghostty#10749](https://github.com/ghostty-org/ghostty/issues/10749#issuecomment-4131892831)) | | cmux | Title match → TTY fallback | CLI new-workspace | Same as iTerm2 (requires cmux v0.63+); requires socket access in cmux Settings (`automation` or `allowAll`) | | VS Code | URI handler (session-level) | `open -b` + URI handler | Requires Claude Code VS Code extension v2.1.72+; `[VSCODE]` badge on active sessions; adaptive resume via IDE lock file polling (~0.5s if project already open) | diff --git a/docs/claude-session-integration-design.md b/docs/claude-session-integration-design.md index 69709a0..d6ea0b7 100644 --- a/docs/claude-session-integration-design.md +++ b/docs/claude-session-integration-design.md @@ -174,6 +174,90 @@ Could supplement with branch name, AI summary, and PR info in Phase 2. The "30 days" in Claude Code's data-usage docs refers to **server-side** retention, not local. Local files are **not observed to be auto-deleted** — `history.jsonl` entries persist 5+ months, session JSONL files persist indefinitely. However, Claude Code could introduce local cleanup in a future version. +## Flow at a glance + +Three flows, in the order a user meets them: the list is built and told which rows are running (§1); a click on a row that is not running opens it (§2); a click on a running row switches to it (§3). Each box names the file that does the work; the sections further down hold the detail and the history. + +### 1. The list, and what is running + +``` + history.jsonl (one per account) ~/.claude/sessions/.json ps -axo pid,tty,rss,etime,args + one line per user prompt written by Claude Code at start, every live `claude` process + sessionId · project · display deleted at exit — best-effort (live-sessions.ts, PR #147) + │ │ │ + ▼ ▼ ▼ + session rows detectActiveSessions() live report + (claude-session-utility.ts) pid alive? ──no──▶ skip registrations ⋈ processes, by pid + id · project · first/last prompt sessionId in history? ──yes──▶ EXACT · registered, or not + promptCount · account └─no─▶ one same-cwd candidate, or · stale registration (ghost) + │ a terminal-tab title ▶ GUESSED · pid · tty · RSS · uptime + │ no sessions/ dir (old CLI): · swap + pressure (sysctl) + │ ps --resume / lsof ▶ GUESSED │ + │ │ │ + ▼ ▼ ▼ + enrichment, async, cached by row.activePid — its provenance is ● live N chip · ·ttysNNN tag + transcript size, persisted in kept (isGuessedPid) and read back on same-titled rows · + ~/.config/codev/enrichment-cache by the switch in §3 ⚠ unregistered · swap chip + title · branch · PR badge · recap purple dot · terminal badge past 8GB or at warn/critical + · PR refs mined from assistant text + │ + ▼ + hook status files (session-status-hooks.ts) → dot colour: working (orange pulse) · + idle (green) · needs-attention (blink); a `working` untouched for 10 min shows idle (#110) +``` + +### 2. Open — a row that is not running + +``` + click / Enter on the row · ⌘+Enter on a project · ▶ open N on a saved list + │ + ▼ + IPC open-claude-session / open-session-list-members (main.ts) + · the project path must be safe to embed in a shell + AppleScript string + · an account label no configured account carries is dropped + · open N: not-running members only, 700 ms apart, each failure reported + │ + ▼ + openSession() (claude-session-utility.ts) — Settings › Terminal decides: + │ + ├─ iTerm2 AppleScript: new tab or window (Settings › Mode), `write text` + ├─ Terminal.app AppleScript: `do script` in a new tab / window + ├─ Ghostty AppleScript: new tab / window, the command as initial input + ├─ cmux CLI: new workspace, then the command + ├─ VS Code `open -b `, then the extension's URI handler + │ once the IDE lock file says the extension is ready + └─ CodeV the embedded Term tab runs it + │ + ▼ + the command: cd "" && command claude --resume + prefixed with CLAUDE_CONFIG_DIR='' when the session belongs to a + non-anchor account (multi-account, PR #122); `command` skips the shell dispatcher +``` + +### 3. Switch — a row that is running (purple dot) + +``` + click on a running row + │ + ▼ + openSession(isActive = true, activePid) + detectTerminalApp(pid): walk the parent processes (up to 20 levels) — + the terminal the process actually lives in wins over Settings › Terminal + │ + ├─ iTerm2 / Terminal.app (terminal-switch.ts builds the AppleScript) + │ isGuessedPid(pid)? + │ no — registered, exact ────▶ tty ─▶ title ─▶ activate only + │ yes — cwd / tab-title guess ─▶ title ─▶ tty ─▶ activate only + │ tty: `ps -o tty= -p ` vs `tty of session` / `tty of tab` + │ title: the /rename title vs `name of session` / `custom title of tab` + ├─ Ghostty title ─▶ cwd ─▶ not found: the resume command goes to the clipboard + │ (no per-tab tty — ghostty#11592, #63) + ├─ cmux `cmux tree --all`: title ─▶ surface tty ─▶ cwd ─▶ project name + ├─ VS Code registration says entrypoint claude-vscode: focus the window, + │ then the URI handler selects the session + └─ CodeV switch to the Term tab +``` + ## Current Implementation ### Architecture @@ -281,13 +365,20 @@ Detection Flow: | **Launch (tab)** | AppleScript: `create tab with default profile` + `write text` | | **Launch (window)** | AppleScript: `create window with default profile` + `write text` | -**Switch matching order (TTY first since PR #152; title first before it):** title-first was a 2026-03-21 workaround (`868db59`) for the era when a same-cwd session's pid was *guessed* from its cwd, so the wrong pid's tty could pick the wrong tab. Since PR #147 the pid comes from Claude Code's own `~/.claude/sessions/.json` registration validated against `ps` (and from `--resume ` args), so it is exact — and titles are the thing that is NOT unique (three `/branch` siblings under 2.1.260 shared one; a manual rename or one session opened twice still does). Remaining gap: an *unregistered* process still gets a guessed pid, where title-first was safer — the order should follow pid provenance (registered → tty first; guessed → title first); not done yet. -1. **TTY match** — match the process TTY against iTerm2 session TTYs. Exact when the pid is exact (registration + `ps` join). -2. **Title match** — fallback: if the session has a `/rename` custom title, match `name of s contains "title"`. Not unique across same-named sessions. - -**Where TTY is relied on** (the list to revisit when Ghostty exposes a per-tab tty, #63): the iTerm2 and Terminal.app switch scripts above, `detectTerminalApp` (parent-process walk), the live view's `·ttysNNN` tag for same-titled rows (`live-sessions.ts`, PR #152), and `openSessionInGhostty`, which today has only title → cwd and would gain the same TTY-first layer. +**Switch matching order follows the pid's provenance (PR #152; `src/terminal-switch.ts`).** Two keys can find the tab, and both are always tried — only the order changes: +1. **TTY match** — the process's tty against iTerm2 session ttys. A process has exactly one controlling terminal, so this cannot pick a sibling *when the pid is right*; it jumps to the guessed process's tab when the pid was a guess. +2. **Title match** — the `/rename` custom title, `name of s contains "title"`. Unique only when the user kept it so (three `/branch` siblings under 2.1.260 shared one; a session opened twice does too). 3. **Not found** — activates iTerm2 without switching. +| Where the pid came from | Order | Why | +|---|---|---| +| `~/.claude/sessions/.json` whose `sessionId` is in `history.jsonl` (or a VS Code registration) — *exact* | tty → title | Registration + `ps` join (PR #147) make the pid exact; titles are the non-unique key | +| Attached by a guess: the registration names a session the history does not know (a fresh `claude` with no prompt yet, a `/branch` child before its first prompt, post-`/clear`), so the pid went to the single same-cwd candidate or to the row whose title matched a terminal tab; or old Claude Code with no registrations (`--resume ` from `ps`, `lsof` cwd) | title → tty | The pre-#152 order. `868db59` (2026-03-21) put title first because a guessed pid's tty had picked the wrong tab during a `claude -r` picker; with a guess in hand the title, when there is one, is the better bet | + +History: title-first was the order from 2026-03-21 to PR #152 because *every* pid was potentially a guess then — the registration file was read (since PR #67) but never validated, so the code could not tell a registered pid from a guessed one. PR #147's `ps` join made that distinction available; PR #152 first flipped the order to tty-first for everything (the `/branch`-sibling case in #142 C0), then narrowed it to exact pids only. `isGuessedPid` in `claude-session-utility.ts` is the classification: whatever the last detection attached outside the two registration paths. + +**Where TTY is relied on** (the list to revisit when Ghostty exposes a per-tab tty, #63): the iTerm2 and Terminal.app switch scripts above, `detectTerminalApp` (parent-process walk), the live view's `·ttysNNN` tag for same-titled rows (the tty comes from `live-sessions.ts`, the tag is rendered in `switcher-ui.tsx`; PR #152), and `openSessionInGhostty`, which today has only title → cwd and would gain the same TTY layer. + **Workarounds discovered:** - `ps -o tty=` output has trailing whitespace → pipe through `tr -d '[:space:]'` - AppleScript inline `-e '...'` fails with embedded double quotes → write to temp `.scpt` file, execute with `osascript ` @@ -426,7 +517,7 @@ Cross-reference: match PID TTY against terminal tab TTYs (iTerm2: `tty of sessio | `claude` or `claude -r` (picker), `/rename`'d but not yet exited | Yes (but detection wrong without cross-ref) | Cross-reference fixes detection ✓ → Title match ✓ | Detection wrong → may click wrong item | | `claude` or `claude -r` (picker), never `/rename`'d | No | **Unsolvable** | cwd fallback ✗ | -**Key difference**: iTerm2 and Terminal.app have TTY matching as fallback — when detection has the correct PID, they can switch correctly even without a custom title (e.g., `claude -r ` without `/rename`). Ghostty lacks per-tab TTY, so without a custom title + same cwd, it falls back to cwd matching which may switch to the wrong tab. cmux also lacks native TTY in AppleScript, but compensates via its `tree --all` CLI which exposes per-surface TTY for cross-reference. +**Key difference**: iTerm2 and Terminal.app have TTY matching — first when the pid is exact, as the fallback when it was guessed (see "Switch matching order" under iTerm2 integration) — so when detection has the correct PID they can switch correctly even without a custom title (e.g., `claude -r ` without `/rename`). Ghostty lacks per-tab TTY, so without a custom title + same cwd, it falls back to cwd matching which may switch to the wrong tab. cmux also lacks native TTY in AppleScript, but compensates via its `tree --all` CLI which exposes per-surface TTY for cross-reference. **Detection with `sessions/` (v1.0.44+)**: Most cases are resolved by direct sessionId matching against history.jsonl. Cross-reference only needed after `/clear` (sessionId mismatch) with multiple same-cwd sessions — a rare combination. The "unsolvable" case (no `/rename` + same cwd) is now limited to cross-reference fallback scenarios, not the primary detection path. diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index c929af8..ed130eb 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -35,6 +35,11 @@ import { writeEnrichmentCacheFile, } from './enrichment-cache'; import { readSessionRegistrations } from './live-sessions'; +import { + buildITerm2SwitchScript, + buildTerminalAppSwitchScript, + switchOrderFor, +} from './terminal-switch'; export interface ClaudeSession { sessionId: string; @@ -100,6 +105,16 @@ let promptTimesBySession: Map = new Map(); // Cache for active session detection to avoid spawning processes on every keystroke let cachedActiveMap: Map | null = null; +// Pids the last detection attached to a row by GUESSING — a same-cwd match or +// a terminal-tab title, for a process whose registration named a session the +// history does not know (or, on old Claude Code, no registration at all). +// Everything else in the map came from `~/.claude/sessions/.json` and +// is exact. The switch scripts read this to pick their matching order. +let cachedGuessedPids: Set = new Set(); + +/** Was this pid attached to its session row by a guess rather than a registration? */ +export const isGuessedPid = (pid: number): boolean => + cachedGuessedPids.has(pid); let cachedVSCodeSessions: ClaudeSession[] | null = null; let cachedEntrypoints: Map | null = null; let activeCacheTimestamp = 0; @@ -238,6 +253,13 @@ export const readClaudeSessions = (limit = 100): ClaudeSession[] => { export interface SessionSearchMatch extends PromptMatch { isLastPrompt: boolean; + /** + * How many prompts the index holds for this session — what a hit's + * `promptIndex` counts against. NOT `messageCount`: that counts every + * history line, including ones with no `display`, so "is this the last + * prompt" must be judged against this figure. + */ + promptCount: number; /** Every hit (up to 20), first one mirrored in the fields above. */ hits: PromptHit[]; /** Epoch ms of the latest hit — what "sort by match" orders on. */ @@ -349,7 +371,9 @@ export const searchClaudeSessions = ( snippets[id] = { promptIndex: first?.promptIndex ?? -1, snippet: first?.snippet ?? '', - isLastPrompt: !!first && first.promptIndex === sessionPrompts.length - 1, + isLastPrompt: + !!first && first.promptIndex === sessionPrompts.length - 1, + promptCount: sessionPrompts.length, hits, matchedAt: hits.reduce((m, h) => Math.max(m, h.at), 0), reasons, @@ -1066,6 +1090,8 @@ export const detectActiveSessions = async (): Promise => { } const activeMap = new Map(); + // Pids whose registration named a session we know: exact, not guessed. + const exactPids = new Set(); const entrypoints = new Map(); const vscodeSessions: ClaudeSession[] = []; const vscodeReadPromises: Promise[] = []; @@ -1113,6 +1139,7 @@ export const detectActiveSessions = async (): Promise => { if (entrypoint === 'claude-vscode') { // VS Code sessions: not in history.jsonl, add directly activeMap.set(sessionId, pid); + exactPids.add(pid); // Queue async JSONL read (head/tail in parallel) const startedAt = data.startedAt; vscodeReadPromises.push( @@ -1137,6 +1164,7 @@ export const detectActiveSessions = async (): Promise => { const knownSession = allSessions.find(s => s.sessionId === sessionId); if (knownSession) { activeMap.set(sessionId, pid); + exactPids.add(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})`); @@ -1179,6 +1207,11 @@ export const detectActiveSessions = async (): Promise => { } cachedActiveMap = activeMap; + // Whatever the cwd / title cross-reference or the legacy scan attached is + // a guess by construction; only the registration paths above are exact. + cachedGuessedPids = new Set( + [...activeMap.values()].filter((pid) => !exactPids.has(pid)), + ); cachedVSCodeSessions = vscodeSessions; cachedEntrypoints = entrypoints; activeCacheTimestamp = now; @@ -1845,56 +1878,15 @@ export const openSessionInITerm2 = async ( const { exec } = require('child_process'); if (isActive && activePid) { - // Three-layer matching for iTerm2 switch: - // 1. tty matching (most precise — works when PID-session mapping is correct) - // 2. title matching (works when session has /rename title) - // 3. fallback: just activate iTerm2 - const titleMatch = customTitle - ? ` - -- Layer 2: title matching (fallback for same-cwd sessions) - repeat with w in windows - repeat with t in tabs of w - repeat with s in sessions of t - if name of s contains "${customTitle.replace(/"/g, '\\"')}" then - select s - select t - set index of w to 1 - return "found-by-title" - end if - end repeat - end repeat - end repeat` - : ''; - + // tty and title, in the order the pid's provenance calls for (see + // terminal-switch.ts): exact pid → tty first; guessed → title first. + // Neither found → iTerm2 is merely activated. + const order = switchOrderFor(!isGuessedPid(activePid)); const tmpScript = '/tmp/codev-iterm-switch.scpt'; - // TTY first: a process has exactly one controlling terminal, so this is - // the only match that cannot pick a sibling. Title matching is the - // fallback, and it is exactly what went wrong before (issue #142 C0): - // three `/branch` siblings share a name on purpose, and the title layer - // sent every row to the same tab. - const switchScript = `tell application "iTerm2" - activate - -- Layer 1: tty matching (exact) - set targetTty to do shell script "ps -o tty= -p ${activePid} 2>/dev/null | tr -d '[:space:]'" - if targetTty is not "" then - repeat with w in windows - repeat with t in tabs of w - repeat with s in sessions of t - if tty of s ends with targetTty then - select s - select t - set index of w to 1 - return "found-by-tty" - end if - end repeat - end repeat - end repeat - end if - ${titleMatch ? `-- Layer 2: title matching (fallback; not unique across /branch siblings) - ${titleMatch.trim()}` : ''} - return "not found" -end tell`; - console.log(`[iTerm2] switch: pid=${activePid}, customTitle=${customTitle || 'none'}`); + const switchScript = buildITerm2SwitchScript(activePid, customTitle, order); + console.log( + `[iTerm2] switch: pid=${activePid}, order=${order}, customTitle=${customTitle || 'none'}`, + ); fs.writeFileSync(tmpScript, switchScript); exec(`osascript ${tmpScript}`, { encoding: 'utf-8' }, (error: any, stdout: string) => { console.log(`[iTerm2] switch result: ${(stdout || '').trim()}`, error?.message || ''); @@ -2598,41 +2590,17 @@ export const openSessionInTerminalApp = async ( const { exec } = require('child_process'); if (isActive && activePid) { - // TTY first (exact), title as the fallback — see the iTerm2 switch for why. - const titleMatch = customTitle - ? ` - -- Layer 2: title matching (fallback; not unique across /branch siblings) - repeat with w in windows - repeat with t in tabs of w - if custom title of t contains "${customTitle.replace(/"/g, '\\"')}" then - set selected tab of w to t - set index of w to 1 - return "found-by-title" - end if - end repeat - end repeat` - : ''; - + // Same two keys and the same provenance rule as the iTerm2 switch. + const order = switchOrderFor(!isGuessedPid(activePid)); const tmpScript = '/tmp/codev-terminal-switch.scpt'; - const switchScript = `tell application "Terminal" - activate - -- Layer 1: tty matching (exact) - set targetTty to do shell script "ps -o tty= -p ${activePid} 2>/dev/null | tr -d '[:space:]'" - if targetTty is not "" then - repeat with w in windows - repeat with t in tabs of w - if tty of t ends with targetTty then - set selected tab of w to t - set index of w to 1 - return "found-by-tty" - end if - end repeat - end repeat - end if - ${titleMatch} - return "not found" -end tell`; - console.log(`[Terminal.app] switch: pid=${activePid}, customTitle=${customTitle || 'none'}`); + const switchScript = buildTerminalAppSwitchScript( + activePid, + customTitle, + order, + ); + console.log( + `[Terminal.app] switch: pid=${activePid}, order=${order}, customTitle=${customTitle || 'none'}`, + ); fs.writeFileSync(tmpScript, switchScript); exec(`osascript ${tmpScript}`, { encoding: 'utf-8', timeout: 5000 }, (error: any, stdout: string) => { console.log(`[Terminal.app] switch result: ${(stdout || '').trim()}`, error?.message || ''); diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 2ea23bf..856d981 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -231,6 +231,8 @@ interface IElectronAPI { snippet: string; promptIndex: number; isLastPrompt: boolean; + /** Prompts in the index for this session — what `promptIndex` counts against (not `messageCount`). */ + promptCount: number; /** Every prompt hit (up to 20): where, when, and the prompts around it. */ hits: { promptIndex: number; @@ -241,8 +243,8 @@ interface IElectronAPI { }[]; /** Epoch ms of the latest hit; 0 when the match was not in a prompt. */ matchedAt: number; - /** Fields the query matched in: title, branch, project, path, prompt, recap, reply, assistant, pr, id. */ - reasons: string[]; + /** Fields the query matched in — the `MatchField` union the main process produces. */ + reasons: import('./session-search').MatchField[]; } >; }>; diff --git a/src/live-sessions.ts b/src/live-sessions.ts index 1f336c9..96f79b7 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -70,7 +70,11 @@ export interface LiveSession { export interface MemoryPressure { swapUsedMb: number; swapTotalMb: number; - /** `kern.memorystatus_vm_pressure_level`: 1 normal, 2 warn, 4 critical; 0 when unreadable. */ + /** + * `kern.memorystatus_vm_pressure_level`: 1 normal, 2 warn, 4 critical. + * Never a placeholder: when either figure is unreadable the whole object + * is absent (`parseMemoryPressure` returns null). + */ level: number; } diff --git a/src/main.ts b/src/main.ts index 6a01e14..d931a07 100644 --- a/src/main.ts +++ b/src/main.ts @@ -115,13 +115,95 @@ const WIN_HEIGHT = 600; let appMode: 'normal' | 'menubar' = 'normal'; // default to normal for new users const getWindowPosition = () => { - const primaryDisplay = screen.getPrimaryDisplay(); - const { width, height } = primaryDisplay.workAreaSize; + // Centred in the primary display's WORK AREA. Its origin is not (0, 0): + // the menu bar offsets it, and a display arrangement can too. + const { x, y, width, height } = screen.getPrimaryDisplay().workArea; + return { + x: x + Math.round((width - WIN_WIDTH) / 2), + y: y + Math.round((height - WIN_HEIGHT) / 2), + }; +}; - const x = Math.round(width / 2 - WIN_WIDTH / 2); - const y = Math.round(height / 2 - WIN_HEIGHT / 2); +// Menu-bar mode: always the default size, centred, not resizable — the +// window may have been created (and resized, and remembered) in normal +// mode, so this is enforced at every show and at every mode switch rather +// than at creation. +const applyMenubarGeometry = (window: BrowserWindow) => { + window.setResizable(false); + window.setSize(WIN_WIDTH, WIN_HEIGHT, false); + const position = getWindowPosition(); + window.setPosition(position.x, position.y, false); +}; - return { x, y }; +// Normal mode remembers the window's bounds (issue #148). Restoring reads +// the settings file, so a window created and shown in the same tick would +// paint at the default size and then jump: `showSwitcherWindow` waits for +// the restore that is in flight. +const SWITCHER_BOUNDS_KEY = 'switcher-window-bounds'; +let pendingBoundsRestore: Promise | null = null; +let saveBoundsTimer: ReturnType | null = null; +// Set by the reset: the setBounds it performs fires resize/move, whose +// debounced save would otherwise write the default bounds straight back. +let ignoreBoundsSavesUntil = 0; + +const restoreSwitcherBounds = async (window: BrowserWindow): Promise => { + try { + const saved = (await settings.get(SWITCHER_BOUNDS_KEY)) as + | { x: number; y: number; width: number; height: number } + | null + | undefined; + if ( + !saved || + typeof saved.x !== 'number' || + typeof saved.y !== 'number' || + typeof saved.width !== 'number' || + typeof saved.height !== 'number' || + window.isDestroyed() || + appMode !== 'normal' + ) { + return; + } + // Only if the rectangle still lands on a display that exists — a window + // remembered on an external monitor must not open off-screen after it + // is unplugged. + const area = screen.getDisplayMatching(saved).workArea; + const onScreen = + saved.x < area.x + area.width && + saved.x + saved.width > area.x && + saved.y < area.y + area.height && + saved.y + saved.height > area.y; + if (onScreen) { + window.setBounds({ + ...saved, + width: Math.max(saved.width, 640), + height: Math.max(saved.height, 420), + }); + } + } catch { + // Unreadable settings: keep the default size. + } +}; + +const saveSwitcherBounds = (window: BrowserWindow) => { + if (saveBoundsTimer) clearTimeout(saveBoundsTimer); + saveBoundsTimer = setTimeout(async () => { + saveBoundsTimer = null; + // Checked when the timer fires, not when the handler was attached: the + // mode can change under a window that stays alive. + if (window.isDestroyed() || appMode !== 'normal') return; + if (Date.now() < ignoreBoundsSavesUntil) return; + const b = window.getBounds(); + try { + await settings.set(SWITCHER_BOUNDS_KEY, { + x: b.x, + y: b.y, + width: b.width, + height: b.height, + }); + } catch { + // A failed save costs the next launch its position, nothing more. + } + }, 400); }; // ref: https://blog.logrocket.com/building-a-menu-bar-application-with-electron-and-react/ @@ -136,21 +218,25 @@ const showSwitcherWindow = () => { } if (appMode === 'menubar') { - // Menu bar mode: always the default size, centred, not resizable — the - // window may have been created (and resized, and remembered) in normal - // mode, so this is enforced at every show rather than at creation. - window.setResizable(false); - window.setSize(WIN_WIDTH, WIN_HEIGHT, false); - const position = getWindowPosition(); - window.setPosition(position.x, position.y, false); + applyMenubarGeometry(window); } else { window.setResizable(true); } - if (window.isMinimized()) { - window.restore(); - } - window.show(); - window.focus(); + const target = window; + const reveal = () => { + if (target.isDestroyed()) return; + if (target.isMinimized()) { + target.restore(); + } + target.show(); + target.focus(); + }; + // A restore still in flight (the window was created a moment ago) + // finishes before the first paint; otherwise show now. + const pending = pendingBoundsRestore; + pendingBoundsRestore = null; + if (pending) void pending.finally(reveal); + else reveal(); }; const showAIAssistantWindow = () => { @@ -477,50 +563,14 @@ const createSwitcherWindow = (initialMode?: string): BrowserWindow => { window.loadURL(SWITCHER_WINDOW_WEBPACK_ENTRY + hash); if (appMode === 'normal') { - // Restore the last bounds before the window is first shown. Only if - // they still land on a display that exists — a window remembered on an - // external monitor must not open off-screen after it is unplugged. - settings - .get('switcher-window-bounds') - .then((saved: unknown) => { - const b = saved as { x: number; y: number; width: number; height: number } | null; - if ( - !b || - typeof b.x !== 'number' || - typeof b.y !== 'number' || - typeof b.width !== 'number' || - typeof b.height !== 'number' || - window.isDestroyed() || - appMode !== 'normal' - ) { - return; - } - const display = screen.getDisplayMatching(b); - const area = display.workArea; - const onScreen = - b.x < area.x + area.width && - b.x + b.width > area.x && - b.y < area.y + area.height && - b.y + b.height > area.y; - if (onScreen) window.setBounds({ ...b, width: Math.max(b.width, 640), height: Math.max(b.height, 420) }); - }) - .catch(() => {}); - let saveBoundsTimer: ReturnType | null = null; - const saveBounds = () => { - if (saveBoundsTimer) clearTimeout(saveBoundsTimer); - saveBoundsTimer = setTimeout(() => { - // Checked when the timer fires, not when the handler was attached: - // the mode can change under a window that stays alive. - if (window.isDestroyed() || appMode !== 'normal') return; - const b = window.getBounds(); - settings - .set('switcher-window-bounds', { x: b.x, y: b.y, width: b.width, height: b.height }) - .catch(() => {}); - }, 400); - }; - window.on('resize', saveBounds); - window.on('move', saveBounds); + // Restore the last bounds before the window is first shown. + pendingBoundsRestore = restoreSwitcherBounds(window); } + // Attached whatever the mode: the save checks the mode when it fires, so + // a window created in menu-bar mode and switched to normal mode later is + // remembered too — the mode changes under a window that stays alive. + window.on('resize', () => saveSwitcherBounds(window)); + window.on('move', () => saveSwitcherBounds(window)); // Open external links in default browser const { shell } = require('electron'); @@ -2364,23 +2414,26 @@ ipcMain.on('set-app-mode', async (_event, mode: string) => { appMode = newMode; if (newMode === 'menubar') { app.setActivationPolicy('accessory'); - // accessory mode hides all windows — re-show after short delay - const win = getSwitcherWindow(); - if (win) { - setTimeout(() => { win.show(); win.focus(); }, 100); + // accessory mode hides all windows — re-show after a short delay, through + // the one path that enforces the menu-bar geometry (default size, + // centred, not resizable) on a window that may have been resized. + if (getSwitcherWindow()) { + setTimeout(() => showSwitcherWindow(), 100); } } else { app.setActivationPolicy('regular'); + // Entering normal mode with a live window: resizable, and the remembered + // bounds restored once, as creation in this mode would have done. + const win = getSwitcherWindow(); + if (win) { + win.setResizable(true); + void restoreSwitcherBounds(win); + } } // Notify renderer to update drag region const window = getSwitcherWindow(); if (window) { window.webContents.send('app-mode-changed', newMode); - // Re-center when switching to menu bar mode - if (newMode === 'menubar') { - const position = getWindowPosition(); - window.setPosition(position.x, position.y, false); - } } }); @@ -2390,9 +2443,23 @@ ipcMain.on('set-app-mode', async (_event, mode: string) => { ipcMain.handle('reset-switcher-window-bounds', async () => { const window = getSwitcherWindow(); if (!window) return; + // The setBounds below fires resize/move; their debounced save must not + // write the default bounds back after the unset. + ignoreBoundsSavesUntil = Date.now() + 1000; + if (saveBoundsTimer) { + clearTimeout(saveBoundsTimer); + saveBoundsTimer = null; + } const position = getWindowPosition(); - window.setBounds({ x: position.x, y: position.y, width: WIN_WIDTH, height: WIN_HEIGHT }, false); - await settings.unset('switcher-window-bounds').catch(() => {}); + window.setBounds( + { x: position.x, y: position.y, width: WIN_WIDTH, height: WIN_HEIGHT }, + false, + ); + try { + await settings.unset(SWITCHER_BOUNDS_KEY); + } catch { + // Nothing to forget, or unwritable settings: the window is reset either way. + } }); ipcMain.handle('get-session-terminal-app', async () => { diff --git a/src/preload.ts b/src/preload.ts index a75e222..7fdc8d2 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -60,7 +60,8 @@ contextBridge.exposeInMainWorld('electronAPI', { onAppModeChanged: (callback: any) => ipcRenderer.on('app-mode-changed', callback), onShortcutsUpdated: (callback: any) => ipcRenderer.on('shortcuts-updated', callback), getSessionTerminalApp: () => ipcRenderer.invoke('get-session-terminal-app'), - resetSwitcherWindowBounds: () => ipcRenderer.invoke('reset-switcher-window-bounds'), + resetSwitcherWindowBounds: () => + ipcRenderer.invoke('reset-switcher-window-bounds'), setSessionTerminalApp: (app: string) => ipcRenderer.send('set-session-terminal-app', app), getSessionTerminalMode: () => ipcRenderer.invoke('get-session-terminal-mode'), setSessionTerminalMode: (mode: string) => ipcRenderer.send('set-session-terminal-mode', mode), diff --git a/src/session-search.test.ts b/src/session-search.test.ts index 3fe0025..793eaa8 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -768,6 +768,12 @@ describe('findPromptHits', () => { expect(hits[1].snippet).toContain('#147'); }); + it('caps context by code point, never splitting a surrogate pair', () => { + const before = 'x'.repeat(199) + '😀' + 'tail'; + const [hit] = findPromptHits([before, 'hit here'], [1, 2], ['hit']); + expect(hit.before).toBe('x'.repeat(199) + '😀…'); + }); + it('has no neighbour past either end, caps long context, and honours the limit', () => { const one = findPromptHits(['x'.repeat(300) + ' hit'], [9], ['hit']); expect(one[0].before).toBeUndefined(); @@ -812,6 +818,27 @@ describe('explainMatch', () => { ]); }); + it('names the field a scoped term hit, reporting msg: as prompt', () => { + const target = { + sessionId: 'abcd1234-0000', + text: '', + title: 'zeta', + recap: 'needle here', + prompts: ['open the vault'], + }; + expect(explainMatch(target, parseQuery('recap:needle', 0))).toEqual([ + 'recap', + ]); + expect(explainMatch(target, parseQuery('msg:vault', 0))).toEqual([ + 'prompt', + ]); + expect(explainMatch(target, parseQuery('title:zeta', 0))).toEqual([ + 'title', + ]); + // The scope is respected: the word is in the recap, not the title. + expect(explainMatch(target, parseQuery('title:needle', 0))).toEqual([]); + }); + it('reports the session id when a word is an id prefix', () => { expect( explainMatch( diff --git a/src/session-search.ts b/src/session-search.ts index 6bd45e5..1b855f9 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -590,12 +590,11 @@ export interface PromptHit extends PromptMatch { } const CONTEXT_CAP = 200; -const capContext = (s: string | undefined): string | undefined => - s === undefined - ? undefined - : s.length > CONTEXT_CAP - ? `${s.slice(0, CONTEXT_CAP)}…` - : s; +const capContext = (s: string | undefined): string | undefined => { + if (s === undefined || s.length <= CONTEXT_CAP) return s; + // Cut by code point: a cut inside a surrogate pair renders as U+FFFD. + return `${Array.from(s).slice(0, CONTEXT_CAP).join('')}…`; +}; /** * EVERY prompt a query hits, in order, up to `limit` — `findPromptMatch` @@ -668,12 +667,20 @@ export const explainMatch = ( ['assistant', t.assistant], ['pr', t.prText], ]; + // A scoped term names its field: `msg:` is the prompt index, the rest are + // the field of the same name (`account:` has no reason line — the account + // chip is always on the row). + const scopedValuesFor = (name: MatchField): string[] => + q.fields + .filter(({ field }) => (field === 'msg' ? 'prompt' : field) === name) + .map(({ value }) => value); const out = new Set(); for (const [name, raw] of fields) { if (!raw) continue; const lower = raw.toLowerCase(); if (q.words.some((w) => lower.includes(w))) out.add(name); if (q.prRefs.some((r) => findPrRef(lower, r, t.repos))) out.add(name); + if (scopedValuesFor(name).some((v) => lower.includes(v))) out.add(name); } if (q.words.some((w) => matchesSessionId(t.sessionId, w))) out.add('id'); return [...out]; diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 3a87c26..830fc65 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -548,6 +548,18 @@ const dotStatus = (v: { status?: string; timestamp?: number } | string): string return status; }; +/** Every status entry through `dotStatus`. */ +const deriveDotStatuses = (raw: Record): Record => { + const out: Record = {}; + for (const [id, v] of Object.entries(raw)) out[id] = dotStatus(v); + return out; +}; + +const sameStatuses = (a: Record, b: Record): boolean => { + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k]); +}; + const formatMb = (kb: number): string => { const mb = kb / 1024; return mb >= 1024 ? `${(mb / 1024).toFixed(1)}GB` : `${Math.round(mb)}MB`; @@ -724,6 +736,30 @@ function SwitcherApp() { const [searchSnippets, setSearchSnippets] = useState>({}); // Mirrored for applySearchFilter (stale-closure trap, see sessionSearchRef2). const searchSnippetsRef = useRef>({}); + // The hook statuses as last received. The dot colour is a function of the + // clock as well (the stale-working rule, #110), so it is re-derived from + // these on a timer — nothing else fires while a session just sits. + const rawStatusesRef = useRef>({}); + const applyStatuses = (rawStatuses: Record) => { + rawStatusesRef.current = rawStatuses; + setSessionStatuses(deriveDotStatuses(rawStatuses)); + }; + useEffect(() => { + const timer = setInterval(() => { + const next = deriveDotStatuses(rawStatusesRef.current); + setSessionStatuses((prev) => (sameStatuses(prev, next) ? prev : next)); + }, 60_000); + return () => clearInterval(timer); + }, []); + // The header's ⤢ (normal mode). `invoke` rejects when the main handler + // throws; neither activation path wants an unhandled rejection for it. + const resetWindowBounds = async () => { + try { + await window.electronAPI.resetSwitcherWindowBounds(); + } catch (err) { + console.warn('[switcher] reset window bounds failed:', err); + } + }; // Issue #146: order results by when the match happened instead of the // session's last activity. Off by default — "what was I just working on" // is the commoner question, and it wants last activity. @@ -2050,19 +2086,10 @@ function SwitcherApp() { // Session status updates from hooks (fs.watch) window.electronAPI.getSessionStatuses().then((rawStatuses: Record) => { if (!rawStatuses) return; - const statusStrings: Record = {}; - for (const [id, v] of Object.entries(rawStatuses)) { - statusStrings[id] = dotStatus(v); - } - setSessionStatuses(statusStrings); + applyStatuses(rawStatuses); }); window.electronAPI.onSessionStatusesUpdated((_event: any, rawStatuses: Record) => { - // Extract status strings for dots display - const statusStrings: Record = {}; - for (const [id, v] of Object.entries(rawStatuses)) { - statusStrings[id] = dotStatus(v); - } - setSessionStatuses(statusStrings); + applyStatuses(rawStatuses); // Auto-refresh preview (user msg + assistant msg + order) for idle sessions const currentSessions = allSessionsRef.current; @@ -2217,11 +2244,7 @@ function SwitcherApp() { // Refresh session statuses on window focus window.electronAPI.getSessionStatuses().then((rawStatuses: Record) => { if (!rawStatuses) return; - const statusStrings: Record = {}; - for (const [id, v] of Object.entries(rawStatuses)) { - statusStrings[id] = dotStatus(v); - } - setSessionStatuses(statusStrings); + applyStatuses(rawStatuses); }); // Refresh display mode setting window.electronAPI.getSessionDisplayMode().then((mode: string) => { @@ -2503,12 +2526,14 @@ function SwitcherApp() { window.electronAPI.resetSwitcherWindowBounds()} + onMouseDown={(e) => e.preventDefault()} + onClick={() => void resetWindowBounds()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - window.electronAPI.resetSwitcherWindowBounds(); + void resetWindowBounds(); } }} style={{ fontSize: '11px', color: '#555', cursor: 'pointer' }} @@ -3632,41 +3657,67 @@ function SwitcherApp() { const hit = hits[k]; const lineStyle = { overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', marginTop: '1px' } as const; const stop = (e: React.SyntheticEvent) => e.stopPropagation(); + // The small controls inside a row: a click must not open + // the session, and must not pull focus off the search + // box; Enter / Space activate them from the keyboard. + const keepFocus = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); }; + const onKeyActivate = (fn: () => void) => (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + fn(); + } + }; const lines: React.ReactNode[] = []; // Issue #146: the matched prompt, with the other hits one // click away and the prompts around it unfoldable. The // line is still suppressed when the only hit is already // on screen (first/last prompt) — vertical space rule. if (hit && words.some((w) => hit.snippet.toLowerCase().includes(w.toLowerCase()))) { - const lastIndex = (session.messageCount ?? 0) - 1; + // Against the prompt index, not messageCount: that + // counts every history line, prompts or not. + const lastIndex = m.promptCount - 1; const dupFirst = hit.promptIndex === 0 && (sessionDisplayMode === 'first' || sessionDisplayMode === 'both'); const dupLast = hit.promptIndex === lastIndex && (sessionDisplayMode === 'last' || sessionDisplayMode === 'both'); const expanded = expandedHits.has(id); const hasContext = !!(hit.before || hit.after); + const prevHit = () => setHitIndex((h) => ({ ...h, [id]: (k - 1 + hits.length) % hits.length })); + const nextHit = () => setHitIndex((h) => ({ ...h, [id]: (k + 1) % hits.length })); + const toggleContext = () => + setExpandedHits((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); if (!(dupFirst || dupLast) || hits.length > 1 || expanded) { lines.push(
match #{hit.promptIndex + 1} {hits.length > 1 && ( - + {' '} setHitIndex((h) => ({ ...h, [id]: (k - 1 + hits.length) % hits.length }))} + onClick={prevHit} + onKeyDown={onKeyActivate(prevHit)} > ‹ {k + 1}/{hits.length} setHitIndex((h) => ({ ...h, [id]: (k + 1) % hits.length }))} + onClick={nextHit} + onKeyDown={onKeyActivate(nextHit)} > › @@ -3675,19 +3726,17 @@ function SwitcherApp() { {hasContext && ( { stop(e); - setExpandedHits((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + toggleContext(); }} + onKeyDown={onKeyActivate(toggleContext)} > {expanded ? '▾ hide' : '▸ context'} diff --git a/src/terminal-switch.test.ts b/src/terminal-switch.test.ts new file mode 100644 index 0000000..5778081 --- /dev/null +++ b/src/terminal-switch.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + buildITerm2SwitchScript, + buildTerminalAppSwitchScript, + escapeAppleScript, + switchOrderFor, +} from './terminal-switch'; + +const builders = [ + ['iTerm2', buildITerm2SwitchScript], + ['Terminal.app', buildTerminalAppSwitchScript], +] as const; + +describe('switch script order follows pid provenance', () => { + it('exact pid → tty first; guessed pid → title first', () => { + expect(switchOrderFor(true)).toBe('tty-first'); + expect(switchOrderFor(false)).toBe('title-first'); + }); + + for (const [name, build] of builders) { + it(`${name}: tty-first tries the tty block before the title block`, () => { + const script = build(4242, 'gg', 'tty-first'); + expect(script.indexOf('found-by-tty')).toBeGreaterThan(-1); + expect(script.indexOf('found-by-tty')).toBeLessThan( + script.indexOf('found-by-title'), + ); + expect(script).toContain('ps -o tty= -p 4242'); + expect(script.trim().endsWith('return "not found"\nend tell')).toBe(true); + }); + + it(`${name}: title-first tries the title block before the tty block, keeping both`, () => { + const script = build(4242, 'gg', 'title-first'); + expect(script.indexOf('found-by-title')).toBeLessThan( + script.indexOf('found-by-tty'), + ); + expect(script).toContain('found-by-tty'); + }); + + it(`${name}: without a title only the tty block is emitted, whatever the order`, () => { + for (const order of ['tty-first', 'title-first'] as const) { + const script = build(7, undefined, order); + expect(script).toContain('found-by-tty'); + expect(script).not.toContain('found-by-title'); + } + }); + + it(`${name}: the title is escaped for an AppleScript string literal`, () => { + const script = build(7, 'say "hi" \\ bye', 'title-first'); + expect(script).toContain('contains "say \\"hi\\" \\\\ bye"'); + }); + } + + it('escapeAppleScript escapes backslashes before quotes', () => { + expect(escapeAppleScript('a\\"b')).toBe('a\\\\\\"b'); + }); +}); diff --git a/src/terminal-switch.ts b/src/terminal-switch.ts new file mode 100644 index 0000000..cb8aaa3 --- /dev/null +++ b/src/terminal-switch.ts @@ -0,0 +1,139 @@ +/** + * The AppleScript that jumps to a running session's tab (iTerm2 and + * Terminal.app; the other terminals have their own paths). Two keys can + * find the tab, and the ORDER they are tried in is the whole point of this + * module: + * + * - **tty** — a process has exactly one controlling terminal, so this cannot + * pick a sibling. Exact when the pid is exact, and wrong when the pid was a + * guess: it then jumps to whatever tab the guessed process lives in. + * - **title** — the session's `/rename` title. Unique only when the user + * kept it so: three `/branch` siblings under Claude Code 2.1.260 shared one + * (issue #142 C0), and a session opened twice does too. + * + * So the order follows where the pid came from (`SwitchOrder`): a pid read + * from Claude Code's own `~/.claude/sessions/.json` and validated + * against `ps` (PR #147) is exact → tty first; a pid attached to a row by + * guessing from its cwd or from a terminal-tab title (the fallback detection + * keeps for processes that never registered) → title first, the pre-#152 + * order that `868db59` (2026-03-21) chose for exactly that case. Both blocks + * are always emitted when a title exists, so the second key is the fallback + * either way. History and the flow diagram: docs/claude-session-integration-design.md. + */ +export type SwitchOrder = 'tty-first' | 'title-first'; + +export const switchOrderFor = (pidExact: boolean): SwitchOrder => + pidExact ? 'tty-first' : 'title-first'; + +/** Inside an AppleScript double-quoted literal: backslash and quote. */ +export const escapeAppleScript = (s: string): string => + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + +const TTY_LOOKUP = (pid: number): string => + `set targetTty to do shell script "ps -o tty= -p ${pid} 2>/dev/null | tr -d '[:space:]'"`; + +const ITERM2_TTY = ( + pid: number, +): string => ` -- tty matching (exact when the pid is) + ${TTY_LOOKUP(pid)} + if targetTty is not "" then + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + if tty of s ends with targetTty then + select s + select t + set index of w to 1 + return "found-by-tty" + end if + end repeat + end repeat + end repeat + end if`; + +const ITERM2_TITLE = ( + title: string, +): string => ` -- title matching (not unique across /branch siblings) + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + if name of s contains "${escapeAppleScript(title)}" then + select s + select t + set index of w to 1 + return "found-by-title" + end if + end repeat + end repeat + end repeat`; + +const TERMINAL_TTY = ( + pid: number, +): string => ` -- tty matching (exact when the pid is) + ${TTY_LOOKUP(pid)} + if targetTty is not "" then + repeat with w in windows + repeat with t in tabs of w + if tty of t ends with targetTty then + set selected tab of w to t + set index of w to 1 + return "found-by-tty" + end if + end repeat + end repeat + end if`; + +const TERMINAL_TITLE = ( + title: string, +): string => ` -- title matching (not unique across /branch siblings) + repeat with w in windows + repeat with t in tabs of w + if custom title of t contains "${escapeAppleScript(title)}" then + set selected tab of w to t + set index of w to 1 + return "found-by-title" + end if + end repeat + end repeat`; + +const assemble = ( + app: 'iTerm2' | 'Terminal', + tty: string, + title: string | undefined, + order: SwitchOrder, +): string => { + const blocks = title + ? order === 'tty-first' + ? [tty, title] + : [title, tty] + : [tty]; + return `tell application "${app}" + activate +${blocks.join('\n')} + return "not found" +end tell`; +}; + +export const buildITerm2SwitchScript = ( + pid: number, + customTitle: string | undefined, + order: SwitchOrder, +): string => + assemble( + 'iTerm2', + ITERM2_TTY(pid), + customTitle ? ITERM2_TITLE(customTitle) : undefined, + order, + ); + +export const buildTerminalAppSwitchScript = ( + pid: number, + customTitle: string | undefined, + order: SwitchOrder, +): string => + assemble( + 'Terminal', + TERMINAL_TTY(pid), + customTitle ? TERMINAL_TITLE(customTitle) : undefined, + order, + ); From e4f03f548e49a55afa195d41c8bb08c73bd19900 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 03:35:03 +0800 Subject: [PATCH 11/13] fix: review round 2; flow diagrams in mermaid, open-vs-switch rule - legacy detection: a `--resume ` pid is exact, not guessed - cachedGuessedPids cleared with its sibling caches - explainMatch: `project:` names the path when only the path matches - capContext gates on code points, so nothing that fit gets an ellipsis - window bounds: the debounced save unsets at the default geometry instead of a time window, the restore promise is kept until it settles (every show during the flight waits), and entering normal mode stores its restore too - design doc: the three flow diagrams are mermaid, with the detail in bullets; how a click decides between open and switch is stated; "both keys tried" qualified for a session with no title (also in the CHANGELOG) Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- docs/claude-session-integration-design.md | 162 ++++++++++++---------- src/claude-session-utility.ts | 15 +- src/main.ts | 64 ++++++--- src/session-search.test.ts | 13 ++ src/session-search.ts | 23 ++- 6 files changed, 176 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d500266..a90b490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - **The prompts around a hit**, one click (`▸`) away on the match line: the prompt before (`↑`) and after (`↓`). The smallest useful version of the reader in #66 — user prompts only; assistant text is not in the index - **`by match`** chip while searching: order results by when the match happened instead of the session's last activity, so the session where you typed the word an hour ago is not buried under one touched five minutes ago. Off by default — "what was I just working on" is the commoner question - **`match path` / `match assistant` / `match recap` / `match reply`** lines say which field a row matched in when that field is not on the row (the path, the assistant's mined references, a recap the row is not showing, a reply hidden behind a recap). Fields that render — title, branch, project name, badge, first/last prompt — already carry the highlight, so they add no line: vertical space stays the scarce resource -- Feat: switch to a running session by its **terminal (tty)** first, title second ([#142](https://github.com/grimmerk/codev/issues/142) C0). Three `/branch` siblings deliberately share a title, and the title-first match sent every one of their rows to the same iTerm2 tab; a process has exactly one tty, so that is what the click matches now (iTerm2 and Terminal.app; Ghostty has no per-tab tty, [#63](https://github.com/grimmerk/codev/issues/63), and keeps title-then-cwd). Running rows that share a title show their tty (`·ttys003`) so they can be told apart on screen. **The order follows where the pid came from**: a pid read from Claude Code's own registration file is exact and goes tty-first; a pid the detection had to *guess* (a same-cwd match or a terminal-tab title, for a process whose registration names a session the history does not know yet) keeps the old title-first order, because a guessed tty can point at a sibling — the case that made title-first the safe choice in 2026-03. Both keys are always tried; only the order changes (`src/terminal-switch.ts`, flow diagram in `docs/claude-session-integration-design.md`) +- Feat: switch to a running session by its **terminal (tty)** first, title second ([#142](https://github.com/grimmerk/codev/issues/142) C0). Three `/branch` siblings deliberately share a title, and the title-first match sent every one of their rows to the same iTerm2 tab; a process has exactly one tty, so that is what the click matches now (iTerm2 and Terminal.app; Ghostty has no per-tab tty, [#63](https://github.com/grimmerk/codev/issues/63), and keeps title-then-cwd). Running rows that share a title show their tty (`·ttys003`) so they can be told apart on screen. **The order follows where the pid came from**: a pid read from Claude Code's own registration file is exact and goes tty-first; a pid the detection had to *guess* (a same-cwd match or a terminal-tab title, for a process whose registration names a session the history does not know yet) keeps the old title-first order, because a guessed tty can point at a sibling — the case that made title-first the safe choice in 2026-03. Both keys are tried when the session has a title, only the order changes; without one, tty is the only key (`src/terminal-switch.ts`, flow diagrams in `docs/claude-session-integration-design.md`) - Feat: a **memory warning chip** beside `● live` when the machine is under pressure — swap past 8GB, or macOS's own pressure level at warn (amber) / critical (red) — with the figures in the live chip's tooltip otherwise. Read from `sysctl vm.swapusage` and `kern.memorystatus_vm_pressure_level` on the same refresh as the process table, so it costs nothing extra. Added the night 42 `claude` processes at 5.1GB pushed a 32GB machine to 18GB of swap: swap was the number that said so first - Feat: **normal app mode's window can be resized** and reopens at its last position and size (a remembered window that would land on an unplugged display is ignored); the header is already a drag region, so the macOS title-bar double-click action applies to it. **Line caps follow the width**: the character caps on title, messages, branch, reply and recap were tuned for the 800px default and now scale up with the list's measured width (never down), so a wider window shows more of each line rather than more empty space; the Projects list grows with the window instead of stopping at 480px with a scrollbar mid-window; a `⤢` beside the shortcut label in the header (normal mode only) resets the window to its default size and position, since macOS zoom only restores the previous user size ([#148](https://github.com/grimmerk/codev/issues/148), both steps). Menu-bar mode is re-asserted at every show — default size, centred, not resizable — so a window resized in normal mode does not carry its size across a mode switch - Fix: **tooltips now show.** Every hint in the switcher (chips, badges, controls, the per-process figures) is drawn by the app instead of relying on the native `title` tooltip, which Chromium paints only while the window is the key window — a frameless popup the pointer merely crosses usually is not, so the hints almost never appeared diff --git a/docs/claude-session-integration-design.md b/docs/claude-session-integration-design.md index d6ea0b7..1b4fffe 100644 --- a/docs/claude-session-integration-design.md +++ b/docs/claude-session-integration-design.md @@ -176,87 +176,109 @@ The "30 days" in Claude Code's data-usage docs refers to **server-side** retenti ## Flow at a glance -Three flows, in the order a user meets them: the list is built and told which rows are running (§1); a click on a row that is not running opens it (§2); a click on a running row switches to it (§3). Each box names the file that does the work; the sections further down hold the detail and the history. +Three flows, in the order a user meets them: the list is built and told which rows are running (§1); a click on a row that is not running opens it (§2); a click on a running row switches to it (§3). The diagrams show the structure; the bullets under each carry the detail, and the sections further down the history. + +**Open or switch — how the click decides.** The row itself says: `isActive` with an `activePid`, set by §1's detection (or by the live report, for a second process on the same session id), means switch; otherwise open. The renderer passes both to `open-claude-session`, `openSession` branches on them — and a running row whose registration says `entrypoint: claude-vscode` takes the VS Code path whatever the terminal setting says. ### 1. The list, and what is running -``` - history.jsonl (one per account) ~/.claude/sessions/.json ps -axo pid,tty,rss,etime,args - one line per user prompt written by Claude Code at start, every live `claude` process - sessionId · project · display deleted at exit — best-effort (live-sessions.ts, PR #147) - │ │ │ - ▼ ▼ ▼ - session rows detectActiveSessions() live report - (claude-session-utility.ts) pid alive? ──no──▶ skip registrations ⋈ processes, by pid - id · project · first/last prompt sessionId in history? ──yes──▶ EXACT · registered, or not - promptCount · account └─no─▶ one same-cwd candidate, or · stale registration (ghost) - │ a terminal-tab title ▶ GUESSED · pid · tty · RSS · uptime - │ no sessions/ dir (old CLI): · swap + pressure (sysctl) - │ ps --resume / lsof ▶ GUESSED │ - │ │ │ - ▼ ▼ ▼ - enrichment, async, cached by row.activePid — its provenance is ● live N chip · ·ttysNNN tag - transcript size, persisted in kept (isGuessedPid) and read back on same-titled rows · - ~/.config/codev/enrichment-cache by the switch in §3 ⚠ unregistered · swap chip - title · branch · PR badge · recap purple dot · terminal badge past 8GB or at warn/critical - · PR refs mined from assistant text - │ - ▼ - hook status files (session-status-hooks.ts) → dot colour: working (orange pulse) · - idle (green) · needs-attention (blink); a `working` untouched for 10 min shows idle (#110) +```mermaid +flowchart TB + subgraph rows["Session rows — claude-session-utility.ts"] + H["history.jsonl, one per account
one line per user prompt"] + R["rows: id · project · first/last prompt
promptCount · account"] + E["enrichment, async, cached by transcript size
title · branch · PR badge · recap · mined PR refs"] + S["hook status files → dot colour
working · idle · needs-attention"] + H --> R --> E --> S + end + subgraph det["Running? — detectActiveSessions()"] + REG["~/.claude/sessions/PID.json
written at start, deleted at exit — best-effort"] + ALIVE{"pid alive?"} + KNOWN{"sessionId in history?"} + EXACT["row.activePid = pid
EXACT"] + GUESS["one same-cwd candidate, or a terminal-tab title
row.activePid = pid — GUESSED"] + DOT["purple dot · terminal badge
provenance kept: isGuessedPid, read by §3"] + REG --> ALIVE + ALIVE -- no --> SKIP["skip"] + ALIVE -- yes --> KNOWN + KNOWN -- yes --> EXACT + KNOWN -- no --> GUESS + EXACT --> DOT + GUESS --> DOT + end + subgraph live["Live view — live-sessions.ts, PR 147"] + PS["ps: every live claude process
pid · tty · RSS · uptime"] + JOIN["registrations ⋈ processes, by pid
registered or not · stale registration"] + CHIP["● live N · ttysNNN tag on same-titled rows
⚠ unregistered · swap chip"] + PS --> JOIN --> CHIP + end + R -.-> KNOWN + REG -.-> JOIN ``` +- **Rows** come from every account's `history.jsonl`; enrichment reads each transcript later (title, branch, PR badge, recap, PR refs mined from the assistant's text) and is persisted in `~/.config/codev/enrichment-cache`. Hook status files (`session-status-hooks.ts`) colour the dot: working (orange pulse), idle (green), needs-attention (blink); a `working` untouched for 10 minutes shows idle (#110). +- **Detection** trusts a registration only when its `sessionId` is in the history (or it is a VS Code registration): that pid is *exact*. Otherwise the pid is attached by a guess — the single same-cwd candidate, or the row whose title matches a terminal tab. With no `sessions/` directory at all (old Claude Code), `ps` supplies the pids: a `--resume ` on the command line is exact, an `lsof` cwd match is a guess. +- **The live view** joins the registrations with the process table, so it also shows processes that never registered, registrations whose process is gone, per-process tty / RSS / uptime, and the machine's swap and memory-pressure figures (`sysctl`); the swap chip appears past 8GB or at warn / critical. + ### 2. Open — a row that is not running -``` - click / Enter on the row · ⌘+Enter on a project · ▶ open N on a saved list - │ - ▼ - IPC open-claude-session / open-session-list-members (main.ts) - · the project path must be safe to embed in a shell + AppleScript string - · an account label no configured account carries is dropped - · open N: not-running members only, 700 ms apart, each failure reported - │ - ▼ - openSession() (claude-session-utility.ts) — Settings › Terminal decides: - │ - ├─ iTerm2 AppleScript: new tab or window (Settings › Mode), `write text` - ├─ Terminal.app AppleScript: `do script` in a new tab / window - ├─ Ghostty AppleScript: new tab / window, the command as initial input - ├─ cmux CLI: new workspace, then the command - ├─ VS Code `open -b `, then the extension's URI handler - │ once the IDE lock file says the extension is ready - └─ CodeV the embedded Term tab runs it - │ - ▼ - the command: cd "" && command claude --resume - prefixed with CLAUDE_CONFIG_DIR='' when the session belongs to a - non-anchor account (multi-account, PR #122); `command` skips the shell dispatcher +```mermaid +flowchart TB + CLICK["click / Enter on a row that is not running
⌘+Enter on a project · ▶ open N on a saved list"] + IPC["IPC open-claude-session / open-session-list-members — main.ts
path safe to embed · unknown account label dropped
open N: not-running members only, 700 ms apart"] + OS["openSession() — Settings › Terminal decides"] + IT["iTerm2
AppleScript: new tab or window, write text"] + TA["Terminal.app
AppleScript: do script"] + GH["Ghostty
AppleScript: new tab or window, initial input"] + CM["cmux
CLI: new workspace"] + VS["VS Code
open -b IDE, then the URI handler once the extension is ready"] + CV["CodeV
the embedded Term tab"] + CMD["cd PROJECT, then: command claude --resume SESSION-ID
CLAUDE_CONFIG_DIR=DIR prefixed for a non-anchor account"] + CLICK --> IPC --> OS + OS --> IT + OS --> TA + OS --> GH + OS --> CM + OS --> CV + OS --> VS + IT --> CMD + TA --> CMD + GH --> CMD + CM --> CMD + CV --> CMD ``` +- The IPC layer refuses a project path that could end a shell or AppleScript string literal, drops an account label no configured account carries, and — for `▶ open N` — resumes only the members that are not running, 700 ms apart, reporting each failure. +- The command is `cd "" && command claude --resume `, prefixed with `CLAUDE_CONFIG_DIR=''` when the session belongs to a non-anchor account (multi-account, PR #122); `command` skips the shell's `claude` dispatcher. VS Code opens the project (`open -b `) and fires the extension's URI handler once the IDE lock file says the extension is ready. + ### 3. Switch — a row that is running (purple dot) +```mermaid +flowchart TB + CLICK["click on a running row"] + OS["openSession(isActive, activePid)
detectTerminalApp(pid): walk the parent processes
the terminal the process lives in wins over the setting"] + PROV{"isGuessedPid(pid)?"} + TTY1["registered, exact
tty → title → activate only"] + TITLE1["cwd / tab-title guess
title → tty → activate only"] + KEYS["terminal-switch.ts builds the AppleScript
tty: ps -o tty= vs tty of session / tab
title: the /rename title vs name of session / custom title of tab
no title → tty only"] + GH["Ghostty
title → cwd → not found: resume command to the clipboard
no per-tab tty (ghostty 11592, issue 63)"] + CM["cmux
cmux tree --all: title → surface tty → cwd → project name"] + VS["VS Code — registration says claude-vscode
focus the window, then the URI handler"] + CV["CodeV
switch to the Term tab"] + CLICK --> OS + OS -- "iTerm2 / Terminal.app" --> PROV + PROV -- no --> TTY1 + PROV -- yes --> TITLE1 + TTY1 --> KEYS + TITLE1 --> KEYS + OS --> GH + OS --> CM + OS --> VS + OS --> CV ``` - click on a running row - │ - ▼ - openSession(isActive = true, activePid) - detectTerminalApp(pid): walk the parent processes (up to 20 levels) — - the terminal the process actually lives in wins over Settings › Terminal - │ - ├─ iTerm2 / Terminal.app (terminal-switch.ts builds the AppleScript) - │ isGuessedPid(pid)? - │ no — registered, exact ────▶ tty ─▶ title ─▶ activate only - │ yes — cwd / tab-title guess ─▶ title ─▶ tty ─▶ activate only - │ tty: `ps -o tty= -p ` vs `tty of session` / `tty of tab` - │ title: the /rename title vs `name of session` / `custom title of tab` - ├─ Ghostty title ─▶ cwd ─▶ not found: the resume command goes to the clipboard - │ (no per-tab tty — ghostty#11592, #63) - ├─ cmux `cmux tree --all`: title ─▶ surface tty ─▶ cwd ─▶ project name - ├─ VS Code registration says entrypoint claude-vscode: focus the window, - │ then the URI handler selects the session - └─ CodeV switch to the Term tab -``` + +- `detectTerminalApp` walks the parent processes (up to 20 levels), so a session running in cmux is switched with cmux's logic even when the setting says iTerm2; the setting only decides where a *new* session opens. +- iTerm2 and Terminal.app try both keys when the session has a title, in the order the pid's provenance calls for (see "Switch matching order" under iTerm2 integration for the table and the history); with no title, tty is the only key. Ghostty has no per-tab tty (#63) and falls back from title to cwd, then copies the resume command to the clipboard. cmux reads its own `tree --all` for surface titles and ttys. ## Current Implementation @@ -365,7 +387,7 @@ Detection Flow: | **Launch (tab)** | AppleScript: `create tab with default profile` + `write text` | | **Launch (window)** | AppleScript: `create window with default profile` + `write text` | -**Switch matching order follows the pid's provenance (PR #152; `src/terminal-switch.ts`).** Two keys can find the tab, and both are always tried — only the order changes: +**Switch matching order follows the pid's provenance (PR #152; `src/terminal-switch.ts`).** Two keys can find the tab, and both are tried — only the order changes (a session with no custom title has only the tty key): 1. **TTY match** — the process's tty against iTerm2 session ttys. A process has exactly one controlling terminal, so this cannot pick a sibling *when the pid is right*; it jumps to the guessed process's tab when the pid was a guess. 2. **Title match** — the `/rename` custom title, `name of s contains "title"`. Unique only when the user kept it so (three `/branch` siblings under 2.1.260 shared one; a session opened twice does too). 3. **Not found** — activates iTerm2 without switching. diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index ed130eb..3004c14 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -132,6 +132,7 @@ export const invalidateSessionCache = () => { flushEnrichmentCache(); cachedSessions = null; cachedActiveMap = null; + cachedGuessedPids = new Set(); cachedVSCodeSessions = null; cachedEntrypoints = null; cachedCustomTitles = null; @@ -1200,15 +1201,16 @@ export const detectActiveSessions = async (): Promise => { // Fallback: if no account had a sessions/ dir (old Claude Code versions) if (!anySessionsDir) { - await detectActiveSessionsLegacy(activeMap); + await detectActiveSessionsLegacy(activeMap, exactPids); } } catch (err) { console.error('[detect-active] Error in detectActiveSessions:', err); } cachedActiveMap = activeMap; - // Whatever the cwd / title cross-reference or the legacy scan attached is - // a guess by construction; only the registration paths above are exact. + // Whatever the cwd / title cross-reference or the legacy cwd scan attached + // is a guess by construction; only a registration whose session the history + // knows, or a `--resume ` on the command line, is exact. cachedGuessedPids = new Set( [...activeMap.values()].filter((pid) => !exactPids.has(pid)), ); @@ -1222,7 +1224,10 @@ export const detectActiveSessions = async (): Promise => { * Legacy detection for old Claude Code versions without ~/.claude/sessions/. * Uses ps aux + regex for --resume UUID, lsof for cwd matching. */ -const detectActiveSessionsLegacy = async (activeMap: Map): Promise => { +const detectActiveSessionsLegacy = async ( + activeMap: Map, + exactPids: Set, +): Promise => { const { exec } = require('child_process'); const execPromise = (cmd: string): Promise => new Promise((resolve) => { @@ -1247,7 +1252,9 @@ const detectActiveSessionsLegacy = async (activeMap: Map): Promi const resumeMatch = line.match(/(?:--resume|-r)\s+([a-f0-9-]{36})/); if (resumeMatch) { + // The command line names the session: as exact as a registration. activeMap.set(resumeMatch[1], pid); + exactPids.add(pid); claimedSessionIds.add(resumeMatch[1]); continue; } diff --git a/src/main.ts b/src/main.ts index d931a07..c74bb5a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -142,9 +142,16 @@ const applyMenubarGeometry = (window: BrowserWindow) => { const SWITCHER_BOUNDS_KEY = 'switcher-window-bounds'; let pendingBoundsRestore: Promise | null = null; let saveBoundsTimer: ReturnType | null = null; -// Set by the reset: the setBounds it performs fires resize/move, whose -// debounced save would otherwise write the default bounds straight back. -let ignoreBoundsSavesUntil = 0; + +const isDefaultBounds = (b: Electron.Rectangle): boolean => { + const d = getWindowPosition(); + return ( + b.width === WIN_WIDTH && + b.height === WIN_HEIGHT && + b.x === d.x && + b.y === d.y + ); +}; const restoreSwitcherBounds = async (window: BrowserWindow): Promise => { try { @@ -184,6 +191,17 @@ const restoreSwitcherBounds = async (window: BrowserWindow): Promise => { } }; +// Kept until it settles, so every show that lands while the read is in +// flight waits for it; cleared only by the restore that set it, since a +// newer one may have replaced it. +const startBoundsRestore = (window: BrowserWindow) => { + const restore = restoreSwitcherBounds(window); + pendingBoundsRestore = restore; + void restore.finally(() => { + if (pendingBoundsRestore === restore) pendingBoundsRestore = null; + }); +}; + const saveSwitcherBounds = (window: BrowserWindow) => { if (saveBoundsTimer) clearTimeout(saveBoundsTimer); saveBoundsTimer = setTimeout(async () => { @@ -191,15 +209,22 @@ const saveSwitcherBounds = (window: BrowserWindow) => { // Checked when the timer fires, not when the handler was attached: the // mode can change under a window that stays alive. if (window.isDestroyed() || appMode !== 'normal') return; - if (Date.now() < ignoreBoundsSavesUntil) return; const b = window.getBounds(); try { - await settings.set(SWITCHER_BOUNDS_KEY, { - x: b.x, - y: b.y, - width: b.width, - height: b.height, - }); + if (isDefaultBounds(b)) { + // At the default geometry there is nothing to remember — and the + // reset's own resize/move land here, so the key it just removed is + // not written back. A resize right after a reset is not at the + // default, and is saved like any other. + await settings.unset(SWITCHER_BOUNDS_KEY); + } else { + await settings.set(SWITCHER_BOUNDS_KEY, { + x: b.x, + y: b.y, + width: b.width, + height: b.height, + }); + } } catch { // A failed save costs the next launch its position, nothing more. } @@ -231,10 +256,10 @@ const showSwitcherWindow = () => { target.show(); target.focus(); }; - // A restore still in flight (the window was created a moment ago) - // finishes before the first paint; otherwise show now. + // A restore still in flight (the window was created, or entered normal + // mode, a moment ago) finishes before the paint; otherwise show now. + // Revealing is idempotent, so every show during the flight may wait on it. const pending = pendingBoundsRestore; - pendingBoundsRestore = null; if (pending) void pending.finally(reveal); else reveal(); }; @@ -564,7 +589,7 @@ const createSwitcherWindow = (initialMode?: string): BrowserWindow => { if (appMode === 'normal') { // Restore the last bounds before the window is first shown. - pendingBoundsRestore = restoreSwitcherBounds(window); + startBoundsRestore(window); } // Attached whatever the mode: the save checks the mode when it fires, so // a window created in menu-bar mode and switched to normal mode later is @@ -2427,7 +2452,7 @@ ipcMain.on('set-app-mode', async (_event, mode: string) => { const win = getSwitcherWindow(); if (win) { win.setResizable(true); - void restoreSwitcherBounds(win); + startBoundsRestore(win); } } // Notify renderer to update drag region @@ -2443,13 +2468,8 @@ ipcMain.on('set-app-mode', async (_event, mode: string) => { ipcMain.handle('reset-switcher-window-bounds', async () => { const window = getSwitcherWindow(); if (!window) return; - // The setBounds below fires resize/move; their debounced save must not - // write the default bounds back after the unset. - ignoreBoundsSavesUntil = Date.now() + 1000; - if (saveBoundsTimer) { - clearTimeout(saveBoundsTimer); - saveBoundsTimer = null; - } + // The setBounds below fires resize/move; their debounced save sees the + // default geometry and unsets rather than saves (saveSwitcherBounds). const position = getWindowPosition(); window.setBounds( { x: position.x, y: position.y, width: WIN_WIDTH, height: WIN_HEIGHT }, diff --git a/src/session-search.test.ts b/src/session-search.test.ts index 793eaa8..ba78a3d 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -772,6 +772,11 @@ describe('findPromptHits', () => { const before = 'x'.repeat(199) + '😀' + 'tail'; const [hit] = findPromptHits([before, 'hit here'], [1, 2], ['hit']); expect(hit.before).toBe('x'.repeat(199) + '😀…'); + // 150 emoji are 300 code units but 150 code points: nothing is cut, so + // nothing is marked as cut. + const emoji = '😀'.repeat(150); + const [fits] = findPromptHits([emoji, 'hit here'], [1, 2], ['hit']); + expect(fits.before).toBe(emoji); }); it('has no neighbour past either end, caps long context, and honours the limit', () => { @@ -837,6 +842,14 @@ describe('explainMatch', () => { ]); // The scope is respected: the word is in the recap, not the title. expect(explainMatch(target, parseQuery('title:needle', 0))).toEqual([]); + // `project:` is matched against name and path alike, so it names the + // path when only the path carries the term. + const proj = { ...target, project: 'codev', path: '/Users/g/git/codev' }; + expect(explainMatch(proj, parseQuery('project:git', 0))).toEqual(['path']); + expect(explainMatch(proj, parseQuery('project:codev', 0)).sort()).toEqual([ + 'path', + 'project', + ]); }); it('reports the session id when a word is an id prefix', () => { diff --git a/src/session-search.ts b/src/session-search.ts index 1b855f9..7b19a06 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -591,9 +591,14 @@ export interface PromptHit extends PromptMatch { const CONTEXT_CAP = 200; const capContext = (s: string | undefined): string | undefined => { + // Code units bound code points from above, so a short string needs no split. if (s === undefined || s.length <= CONTEXT_CAP) return s; - // Cut by code point: a cut inside a surrogate pair renders as U+FFFD. - return `${Array.from(s).slice(0, CONTEXT_CAP).join('')}…`; + // Count and cut by code point: a cut inside a surrogate pair renders as + // U+FFFD, and a code-unit count would put an ellipsis on text that fit. + const points = Array.from(s); + return points.length <= CONTEXT_CAP + ? s + : `${points.slice(0, CONTEXT_CAP).join('')}…`; }; /** @@ -667,12 +672,18 @@ export const explainMatch = ( ['assistant', t.assistant], ['pr', t.prText], ]; - // A scoped term names its field: `msg:` is the prompt index, the rest are - // the field of the same name (`account:` has no reason line — the account - // chip is always on the row). + // A scoped term names its field: `msg:` is the prompt index; `project:` is + // matched against the project name AND its path (the matcher's `project` + // field is both), so it can name either; the rest are the field of the + // same name (`account:` has no reason line — the account chip is always on + // the row). const scopedValuesFor = (name: MatchField): string[] => q.fields - .filter(({ field }) => (field === 'msg' ? 'prompt' : field) === name) + .filter( + ({ field }) => + (field === 'msg' ? 'prompt' : field) === name || + (field === 'project' && name === 'path'), + ) .map(({ value }) => value); const out = new Set(); for (const [name, raw] of fields) { From 789d2315a0ad203d247df623962bb05ae0589f41 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 04:01:16 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20review=20round=203=20=E2=80=94=20l?= =?UTF-8?q?egacy=20ps=20uses=20the=20session=20rule;=20bounds=20at=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - legacy detection reads the `ps aux` command column through the live view's isSessionArgs (one-shots and helpers excluded) and sessionIdFromArgs, so a `-p` prompt mentioning `--resume ` can no longer become an exact pid - window bounds are captured when the resize/move event fires in normal mode; a menu-bar geometry event neither saves nor replaces a pending normal-mode save - docs: Terminal.app row and the support matrix state the provenance order; cmux row gains its tty layer; README says up to 20 hits Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/claude-session-integration-design.md | 8 +++---- src/claude-session-utility.ts | 28 +++++++++++++++-------- src/live-sessions.ts | 8 +++++-- src/main.ts | 11 +++++---- 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 094af4e..55e0ee9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Bare words search everything; **operators aim the query** (the `?` chip beside t Every term must hold. An operator with an unreadable value (`after:soon`) is reported under the box and ignored rather than silently matching nothing. -A result also says **why and when**: the `match #N` line steps through every prompt hit in a session (`‹ 2/12 ›`) and unfolds (`▸`) the prompt before and after the hit; a `by match` chip orders results by when the match happened rather than by the session's last activity; and when the matching field is not on the row — the project path, something the assistant said, a recap the row is not showing — a `match path` / `match assistant` / `match recap` / `match reply` line names it. Fields that are on the row (title, branch, project name, PR badge, first/last prompt) already carry the highlight and add no line. +A result also says **why and when**: the `match #N` line steps through a session's prompt hits, up to 20 of them (`‹ 2/12 ›`) and unfolds (`▸`) the prompt before and after the hit; a `by match` chip orders results by when the match happened rather than by the session's last activity; and when the matching field is not on the row — the project path, something the assistant said, a recap the row is not showing — a `match path` / `match assistant` / `match recap` / `match reply` line names it. Fields that are on the row (title, branch, project name, PR badge, first/last prompt) already carry the highlight and add no line. #### Finding a pull request diff --git a/docs/claude-session-integration-design.md b/docs/claude-session-integration-design.md index 1b4fffe..8eaa2e0 100644 --- a/docs/claude-session-integration-design.md +++ b/docs/claude-session-integration-design.md @@ -410,7 +410,7 @@ History: title-first was the order from 2026-03-21 to PR #152 because *every* pi | Action | Method | |--------|--------| | **Detect** | Process tree walk → `commLower === 'terminal'` or `commLower.includes('terminal.app')` | -| **Switch** | Two-layer AppleScript matching: (1) TTY match → (2) title fallback (PR #152) | +| **Switch** | Two-layer AppleScript matching, ordered by pid provenance like iTerm2 (PR #152): exact pid → TTY then title; guessed pid → title then TTY | | **Launch (tab)** | AppleScript: `do script "cmd" in front window` | | **Launch (window)** | AppleScript: `do script "cmd"` (standalone) | @@ -494,10 +494,10 @@ Session-related settings are only visible when in Sessions mode (fixes popup int | Terminal | Detect | Switch | Launch | External Access | |----------|--------|--------|--------|----------------| -| iTerm2 ✅ | `ps` + `lsof` + tty | Title match → TTY fallback | AppleScript: new tab/window + execute | No restriction | +| iTerm2 ✅ | `ps` + `lsof` + tty | Exact pid: TTY match → title fallback; guessed pid: title → TTY (PR #152) | AppleScript: new tab/window + execute | No restriction | | Ghostty ✅ | `ps` + parent tree | Title match → cwd fallback | AppleScript: `new tab`/`new window` with `surface configuration` | No restriction | -| cmux ✅ | `ps` + `lsof` | Title match → cwd fallback → project name fallback (surface-level) | `cmux new-workspace --cwd --command` | Requires socket `automation`/`allowAll` | -| Terminal.app ✅ | `ps` + tty | Title match → TTY fallback | AppleScript: new tab/window + execute | No restriction | +| cmux ✅ | `ps` + `lsof` | Title match → surface TTY → cwd fallback → project name fallback (surface-level) | `cmux new-workspace --cwd --command` | Requires socket `automation`/`allowAll` | +| Terminal.app ✅ | `ps` + tty | Same order rule as iTerm2 | AppleScript: new tab/window + execute | No restriction | | Custom | — | — | User command template / clipboard | — | ### Same-CWD Session Matching diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 3004c14..c45047b 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -34,7 +34,11 @@ import { readEnrichmentCacheFile, writeEnrichmentCacheFile, } from './enrichment-cache'; -import { readSessionRegistrations } from './live-sessions'; +import { + isSessionArgs, + readSessionRegistrations, + sessionIdFromArgs, +} from './live-sessions'; import { buildITerm2SwitchScript, buildTerminalAppSwitchScript, @@ -1222,7 +1226,8 @@ export const detectActiveSessions = async (): Promise => { /** * Legacy detection for old Claude Code versions without ~/.claude/sessions/. - * Uses ps aux + regex for --resume UUID, lsof for cwd matching. + * Uses ps aux (the live view's session rule, then `--resume ` from the + * arguments), lsof for cwd matching. */ const detectActiveSessionsLegacy = async ( activeMap: Map, @@ -1250,18 +1255,23 @@ const detectActiveSessionsLegacy = async ( const pid = parseInt(parts[1], 10); if (!pid) continue; - const resumeMatch = line.match(/(?:--resume|-r)\s+([a-f0-9-]{36})/); - if (resumeMatch) { + // `ps aux` columns: USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME, then + // the command. The live view's rule decides what is a session at all — + // a `-p` one-shot whose prompt happens to mention `--resume ` is not + // one, and must not become an exact pid for the switch. + const args = parts.slice(10).join(' '); + if (!isSessionArgs(args)) continue; + + const resumed = sessionIdFromArgs(args); + if (resumed) { // The command line names the session: as exact as a registration. - activeMap.set(resumeMatch[1], pid); + activeMap.set(resumed, pid); exactPids.add(pid); - claimedSessionIds.add(resumeMatch[1]); + claimedSessionIds.add(resumed); continue; } - if (line.includes('claude')) { - cwdProcesses.push({ pid, line }); - } + cwdProcesses.push({ pid, line }); } const allSessions = readClaudeSessions(500); diff --git a/src/live-sessions.ts b/src/live-sessions.ts index 96f79b7..69933bf 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -204,8 +204,12 @@ const isClaudeBinary = (token: string): boolean => * hidden from the live scope while it runs. That is rare, temporary, and * visible; a one-shot leaking into a saved list is a stale member forever. */ -export const isSessionProcess = (p: ClaudeProcess): boolean => { - const tokens = p.args.split(/\s+/); +export const isSessionProcess = (p: ClaudeProcess): boolean => + isSessionArgs(p.args); + +/** The same rule on a bare command line (`ps` COMMAND column). */ +export const isSessionArgs = (args: string): boolean => { + const tokens = args.split(/\s+/); if (!tokens[0] || !isClaudeBinary(tokens[0])) return false; const first = tokens[1]; if (first && !first.startsWith('-') && NON_SESSION_SUBCOMMANDS.has(first)) { diff --git a/src/main.ts b/src/main.ts index c74bb5a..94a1fff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -203,13 +203,16 @@ const startBoundsRestore = (window: BrowserWindow) => { }; const saveSwitcherBounds = (window: BrowserWindow) => { + // Only a normal-mode event carries bounds worth remembering. Checked when + // the event fires, not when the handler was attached: the mode can change + // under a window that stays alive — and the menu-bar geometry applied by + // that switch fires resize/move of its own, which must neither be saved + // nor replace a normal-mode save still pending from a moment before. + if (window.isDestroyed() || appMode !== 'normal') return; + const b = window.getBounds(); if (saveBoundsTimer) clearTimeout(saveBoundsTimer); saveBoundsTimer = setTimeout(async () => { saveBoundsTimer = null; - // Checked when the timer fires, not when the handler was attached: the - // mode can change under a window that stays alive. - if (window.isDestroyed() || appMode !== 'normal') return; - const b = window.getBounds(); try { if (isDefaultBounds(b)) { // At the default geometry there is nothing to remember — and the From e6e0e5343dead930e5f5031a38308827cedeb4fb Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 6 Sep 2026 04:10:24 +0800 Subject: [PATCH 13/13] fix(window): reset drops a bounds save still pending from before it A resize leaves a 400 ms debounced save holding the pre-reset rectangle; a reset that landed inside that window (via a mode switch and back) had its unset overwritten when the timer fired. The reset now clears the pending timer first; its own resize/move then schedule a save that sees the default geometry and unsets. Co-Authored-By: Claude Fable 5.1 --- src/main.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main.ts b/src/main.ts index 94a1fff..b7d35e7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2471,8 +2471,14 @@ ipcMain.on('set-app-mode', async (_event, mode: string) => { ipcMain.handle('reset-switcher-window-bounds', async () => { const window = getSwitcherWindow(); if (!window) return; - // The setBounds below fires resize/move; their debounced save sees the - // default geometry and unsets rather than saves (saveSwitcherBounds). + // A save still pending from before the reset holds the pre-reset + // rectangle and would write it after the unset below: drop it. The + // setBounds that follows fires resize/move of its own, whose save sees + // the default geometry and unsets rather than saves (saveSwitcherBounds). + if (saveBoundsTimer) { + clearTimeout(saveBoundsTimer); + saveBoundsTimer = null; + } const position = getWindowPosition(); window.setBounds( { x: position.x, y: position.y, width: WIN_WIDTH, height: WIN_HEIGHT },