diff --git a/CHANGELOG.md b/CHANGELOG.md index b650104..a90b490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 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)) + - **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. **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 +- 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..55e0ee9 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 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 A PR can be found from two kinds of evidence, both indexed: @@ -84,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. @@ -99,12 +101,20 @@ 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) | +#### 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/docs/claude-session-integration-design.md b/docs/claude-session-integration-design.md index bad7e42..8eaa2e0 100644 --- a/docs/claude-session-integration-design.md +++ b/docs/claude-session-integration-design.md @@ -174,6 +174,112 @@ 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). 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 + +```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 + +```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 +``` + +- `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 ### Architecture @@ -277,15 +383,24 @@ 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 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. +| 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 ` @@ -295,7 +410,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, 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) | @@ -379,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 @@ -424,7 +539,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/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..c45047b 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, @@ -31,7 +34,16 @@ import { readEnrichmentCacheFile, writeEnrichmentCacheFile, } from './enrichment-cache'; -import { readSessionRegistrations } from './live-sessions'; +import { + isSessionArgs, + readSessionRegistrations, + sessionIdFromArgs, +} from './live-sessions'; +import { + buildITerm2SwitchScript, + buildTerminalAppSwitchScript, + switchOrderFor, +} from './terminal-switch'; export interface ClaudeSession { sessionId: string; @@ -91,9 +103,22 @@ 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; +// 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; @@ -111,6 +136,7 @@ export const invalidateSessionCache = () => { flushEnrichmentCache(); cachedSessions = null; cachedActiveMap = null; + cachedGuessedPids = new Set(); cachedVSCodeSessions = null; cachedEntrypoints = null; cachedCustomTitles = null; @@ -140,6 +166,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 +186,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 +251,26 @@ 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; + /** + * 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. */ + matchedAt: number; + /** Which fields matched, for the "why is this row here" marker (#141). */ + reasons: MatchField[]; } export interface SessionSearchResult { @@ -303,16 +348,40 @@ 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, + promptCount: sessionPrompts.length, + hits, + matchedAt: hits.reduce((m, h) => Math.max(m, h.at), 0), + reasons, }; } if (sessions.length >= limit) break; @@ -1026,6 +1095,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[] = []; @@ -1073,6 +1144,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( @@ -1097,6 +1169,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})`); @@ -1132,13 +1205,19 @@ 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 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)), + ); cachedVSCodeSessions = vscodeSessions; cachedEntrypoints = entrypoints; activeCacheTimestamp = now; @@ -1147,9 +1226,13 @@ 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): Promise => { +const detectActiveSessionsLegacy = async ( + activeMap: Map, + exactPids: Set, +): Promise => { const { exec } = require('child_process'); const execPromise = (cmd: string): Promise => new Promise((resolve) => { @@ -1172,16 +1255,23 @@ const detectActiveSessionsLegacy = async (activeMap: Map): Promi const pid = parseInt(parts[1], 10); if (!pid) continue; - const resumeMatch = line.match(/(?:--resume|-r)\s+([a-f0-9-]{36})/); - if (resumeMatch) { - activeMap.set(resumeMatch[1], pid); - claimedSessionIds.add(resumeMatch[1]); + // `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(resumed, pid); + exactPids.add(pid); + claimedSessionIds.add(resumed); continue; } - if (line.includes('claude')) { - cwdProcesses.push({ pid, line }); - } + cwdProcesses.push({ pid, line }); } const allSessions = readClaudeSessions(500); @@ -1805,51 +1895,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'; - const switchScript = `tell application "iTerm2" - activate - ${titleMatch ? `-- Layer 1: title matching (most precise for same-cwd sessions) - ${titleMatch.trim()}` : ''} - -- Layer 2: tty matching (fallback) - 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 - 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 || ''); @@ -2553,41 +2607,17 @@ export const openSessionInTerminalApp = async ( const { exec } = require('child_process'); if (isActive && activePid) { - // Two-layer matching: title first, then TTY - const titleMatch = customTitle - ? ` - -- Layer 1: title matching - 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 - ${titleMatch} - -- Layer 2: TTY matching - 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 - 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 6c238e4..856d981 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; @@ -215,6 +217,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 @@ -223,7 +227,25 @@ interface IElectronAPI { sessions: any[]; snippets: Record< string, - { snippet: string; promptIndex: number; isLastPrompt: boolean } + { + 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; + 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 — the `MatchField` union the main process produces. */ + reasons: import('./session-search').MatchField[]; + } >; }>; detectActiveSessions: () => Promise<{ diff --git a/src/live-sessions.test.ts b/src/live-sessions.test.ts index ed4ee47..6734247 100644 --- a/src/live-sessions.test.ts +++ b/src/live-sessions.test.ts @@ -5,11 +5,33 @@ 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 +277,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..69933bf 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -60,14 +60,56 @@ 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. + * Never a placeholder: when either figure is unreadable the whole object + * is absent (`parseMemoryPressure` returns null). + */ + 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+)$/); @@ -162,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)) { @@ -312,6 +358,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 +371,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 +396,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..b7d35e7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -115,13 +115,123 @@ 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), + }; +}; + +// 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); +}; + +// 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; + +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 { + 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 x = Math.round(width / 2 - WIN_WIDTH / 2); - const y = Math.round(height / 2 - WIN_HEIGHT / 2); +// 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; + }); +}; - return { x, y }; +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; + try { + 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. + } + }, 400); }; // ref: https://blog.logrocket.com/building-a-menu-bar-application-with-electron-and-react/ @@ -136,15 +246,25 @@ const showSwitcherWindow = () => { } if (appMode === 'menubar') { - // Menu bar mode: always center on screen - const position = getWindowPosition(); - window.setPosition(position.x, position.y, false); - } - if (window.isMinimized()) { - window.restore(); + applyMenubarGeometry(window); + } else { + window.setResizable(true); } - 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, 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; + if (pending) void pending.finally(reveal); + else reveal(); }; const showAIAssistantWindow = () => { @@ -456,7 +576,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 +590,16 @@ 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. + 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 + // 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'); window.webContents.setWindowOpenHandler(({ url }: { url: string }) => { @@ -2307,23 +2442,52 @@ 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); + startBoundsRestore(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); - } + } +}); + +// 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; + // 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 }, + false, + ); + try { + await settings.unset(SWITCHER_BOUNDS_KEY); + } catch { + // Nothing to forget, or unwritable settings: the window is reset either way. } }); diff --git a/src/preload.ts b/src/preload.ts index 235f64b..7fdc8d2 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -60,6 +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'), 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 63b2ff0..ba78a3d 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,119 @@ 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('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) + '😀…'); + // 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', () => { + 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('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([]); + // `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', () => { + 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..7b19a06 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), }; } } @@ -579,6 +580,123 @@ 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 => { + // Code units bound code points from above, so a short string needs no split. + if (s === undefined || s.length <= CONTEXT_CAP) return s; + // 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('')}…`; +}; + +/** + * 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++) { + // 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, + 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], + ]; + // 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 || + (field === 'project' && name === 'path'), + ) + .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]; +}; + /** * 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 122bcae..830fc65 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 = { @@ -522,6 +526,40 @@ 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; +}; + +/** 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`; @@ -545,6 +583,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); @@ -633,7 +733,42 @@ 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>({}); + // 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. + 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. @@ -714,6 +849,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 @@ -922,11 +1071,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; }; @@ -967,6 +1121,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 @@ -1029,6 +1184,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; @@ -1039,10 +1197,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 = @@ -1175,6 +1340,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. @@ -1893,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] = typeof v === 'object' ? v.status : 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] = typeof v === 'object' ? v.status : v; - } - setSessionStatuses(statusStrings); + applyStatuses(rawStatuses); // Auto-refresh preview (user msg + assistant msg + order) for idle sessions const currentSessions = allSessionsRef.current; @@ -2060,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] = typeof v === 'object' ? v.status : v; - } - setSessionStatuses(statusStrings); + applyStatuses(rawStatuses); }); // Refresh display mode setting window.electronAPI.getSessionDisplayMode().then((mode: string) => { @@ -2334,7 +2514,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'; }} @@ -2342,6 +2522,27 @@ function SwitcherApp() { {quickSwitcherShortcut} )} + {currentAppMode === 'normal' && ( + e.preventDefault()} + onClick={() => void resetWindowBounds()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + void resetWindowBounds(); + } + }} + style={{ fontSize: '11px', color: '#555', cursor: 'pointer' }} + onMouseEnter={(e) => { e.currentTarget.style.color = '#888'; }} + onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; }} + > + ⤢ + + )}
e.preventDefault()} onClick={() => setSearchHelpOpen((v) => !v)} onKeyDown={(e) => { @@ -2582,6 +2783,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. */} @@ -2589,10 +2819,10 @@ 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` : ''}` - : `Show only sessions with a running process${staleCount ? ` · ${staleCount} stale registration${staleCount > 1 ? 's' : ''} in ~/.claude/sessions` : ''}` + ? `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}` : ''}` } onMouseDown={(e) => e.preventDefault()} onClick={toggleLiveOnly} @@ -2606,12 +2836,29 @@ 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 && ( e.preventDefault()} onClick={(e) => { // The document-level click handler refocuses the search box @@ -2656,7 +2903,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 @@ -2757,17 +3004,17 @@ function SwitcherApp() { ⚠ {listsNotice}
)} -
+
{/* 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(); @@ -2816,7 +3063,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 @@ -2850,7 +3097,7 @@ function SwitcherApp() { e.preventDefault()} onClick={closeList} onKeyDown={(e) => { @@ -2893,7 +3140,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) => { @@ -2916,7 +3163,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(); @@ -2937,7 +3184,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(); @@ -2970,7 +3217,7 @@ function SwitcherApp() { )} {hiddenSet.has(session.sessionId) && ( - + )} + {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) && ( @@ -3169,7 +3436,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' }} @@ -3177,7 +3444,7 @@ function SwitcherApp() { 📌 e.preventDefault()} onClick={(e) => { e.stopPropagation(); toggleHide(session); }} style={{ cursor: 'pointer', fontSize: '11px', color: hiddenSet.has(session.sessionId) ? '#e07a5f' : '#666' }} @@ -3206,7 +3473,7 @@ function SwitcherApp() { {liveStats && ( {formatMb(live.rssKb)} · {formatUptime(live.uptimeSec)} @@ -3214,7 +3481,7 @@ function SwitcherApp() { {!live.registered && ( ⚠ unregistered @@ -3222,7 +3489,7 @@ function SwitcherApp() { {session.__liveExtra && ( ⚠ 2nd process @@ -3246,7 +3513,7 @@ function SwitcherApp() { padding: '1px 5px', fontFamily: 'Menlo, monospace', }} - title={session.sessionId} + data-tip={session.sessionId} > id {session.sessionId.slice(0, 8)} @@ -3272,7 +3539,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); @@ -3297,7 +3564,7 @@ function SwitcherApp() { padding: '1px 4px', textTransform: 'uppercase', }} - title={`Claude account: ${session.accountLabel}`} + data-tip={`Claude account: ${session.accountLabel}`} > {session.accountLabel} @@ -3384,26 +3651,158 @@ 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(); + // 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()))) { + // 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 && ( + + {' '} + + ‹ + + {k + 1}/{hits.length} + + › + + + )} + {hasContext && ( + { + stop(e); + toggleContext(); + }} + onKeyDown={onKeyActivate(toggleContext)} + > + {expanded ? '▾ hide' : '▸ context'} + + )}{' '} + + +
, + ); + 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 @@ -3424,7 +3823,7 @@ function SwitcherApp() { null, DropdownIndicator: () => (
{isMultiAccountUI @@ -3729,7 +4128,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 - 120px)', + overflowX: 'hidden', }), option: (base) => ({ ...base, @@ -3906,7 +4308,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'); }); 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, + );