diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b5287..8727171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.0.94 + +- Feat: **Ghostty switches to the right tab when several sessions share a directory** ([#63](https://github.com/grimmerk/codev/issues/63)). Ghostty's tip builds expose each terminal's `tty` (ghostty-org/ghostty#11922), so switching now works the way it does in iTerm2 and Terminal.app: by tty and by `/rename` title, in the order the session's process provenance calls for, and only then by working directory. Before, a session without a custom title went to the first tab in its directory — measured on one machine with 16 sessions sharing `~/git`, every one of them landed on the same tab. Detection's fallback cross-reference uses the tty the same way. **Stable Ghostty (1.3.1 and earlier) has no `tty`**, so there CodeV behaves exactly as before — title, then directory — until a release ships it +- Fix: detection's fallback cross-reference no longer gives a running process to the wrong session when one `/rename` title contains another (`Fix` inside `Fix backend`). Every terminal (iTerm2, cmux, Terminal.app, Ghostty) took the first title its tab matched, from every transcript in the directory; it now takes the longest match among the sessions actually queued for that process. One function for all four, where there used to be four copies + ## 1.0.93 - Fix: **`⌃+⌘+R` no longer comes back to the Term tab.** Leave the app sitting on Term, switch to another app, press the Quick Switcher shortcut, and it reopened on Term — but Term has its own shortcut (`⌃+⌘+T`), and the switcher is what `⌃+⌘+R` is for. When the switcher is shown from the Term tab, it now moves to whatever **Settings → General → Default Tab** names; a window parked on Projects or Sessions keeps its place, so this is not a reset-to-default on every show. When Default Tab is itself *Terminal*, staying on Term is the same rule, not an exception. Two causes, neither of them a state bug: Default Tab was applied only when a window was created, and the Quick Switcher callback only showed, hid or focused the window without ever naming a tab diff --git a/README.md b/README.md index b75dd5c..865204a 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ For the full same-cwd accuracy matrix (detection + switch by launch method and t |----------|--------------|---------------|-------| | 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)) | +| Ghostty | TTY and title, ordered like iTerm2 (TTY first for an exact pid, title first for a guessed one), then cwd fallback. TTY needs a Ghostty tip build; stable 1.3.x: title → cwd | AppleScript new tab/window | On stable Ghostty, same-cwd sessions need `/rename`. **Note:** Ghostty may not support `⌘+V` (paste) and `⌘+Z` (undo) in CodeV's search bar by default — add `keybind = super+v=paste_from_clipboard` and `keybind = super+z=undo` to `~/.config/ghostty/config` ([ghostty#10749](https://github.com/ghostty-org/ghostty/issues/10749#issuecomment-4131892831)) | | cmux | Title match → TTY fallback | CLI new-workspace | Same as iTerm2 (requires cmux v0.63+); requires socket access in cmux Settings (`automation` or `allowAll`) | | VS Code | URI handler (session-level) | `open -b` + URI handler | Requires Claude Code VS Code extension v2.1.72+; `[VSCODE]` badge on active sessions; adaptive resume via IDE lock file polling (~0.5s if project already open) | diff --git a/docs/claude-session-integration-design.md b/docs/claude-session-integration-design.md index 8eaa2e0..345c960 100644 --- a/docs/claude-session-integration-design.md +++ b/docs/claude-session-integration-design.md @@ -261,7 +261,7 @@ flowchart TB 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)"] + GH["Ghostty
tty and title in pid order → cwd → not found: resume command to the clipboard
tty needs a Ghostty tip build (ghostty 11922, 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"] @@ -278,7 +278,7 @@ flowchart TB ``` - `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. +- 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 does the same on tip builds, which expose a per-terminal tty (ghostty#11922, #63), and keeps cwd as a last resort before copying the resume command to the clipboard; stable Ghostty (1.3.1 and earlier) has no tty, so there it falls through to title, then cwd. cmux reads its own `tree --all` for surface titles and ttys. ## Current Implementation @@ -340,7 +340,7 @@ Detection Flow: │ ├─ iTerm2: AppleScript TTY+name → match custom titles (~130ms) │ │ ├─ Terminal.app: AppleScript TTY+title → match custom titles │ │ ├─ cmux: tree --all tty= → match custom titles (~70ms) │ -│ └─ Ghostty/other: cwd fallback (no TTY upstream) │ +│ └─ Ghostty: TTY on tip builds; other / unmatched: cwd fallback │ ├─────────────────────────────────────────────────────────────────────┤ │ 4. Fallback: if sessions/ doesn't exist (old Claude Code) │ │ → Legacy detection (ps aux + regex + lsof) │ @@ -399,7 +399,7 @@ Detection Flow: 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. +**Where TTY is relied on** (Ghostty joined this list in 2026-10, #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` plus Ghostty's branch of `crossRefDisambiguate`, which gained the same TTY layer once Ghostty tip builds exposed it (read as the raw code `«class Gtty»` inside `try`, so stable Ghostty still compiles and falls through). **Workarounds discovered:** - `ps -o tty=` output has trailing whitespace → pipe through `tr -d '[:space:]'` @@ -495,7 +495,7 @@ Session-related settings are only visible when in Sessions mode (fixes popup int | Terminal | Detect | Switch | Launch | External Access | |----------|--------|--------|--------|----------------| | 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 | +| Ghostty ✅ | `ps` + parent tree | Tip build: TTY and title in pid-provenance order → cwd fallback. Stable 1.3.x: title → cwd | AppleScript: `new tab`/`new window` with `surface configuration` | 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 | — | @@ -514,7 +514,7 @@ When multiple sessions share the same project path, there are two separate conce | After `/clear` (before exit) | sessionId mismatch → cwd fallback | 1 same-cwd: ✓; multiple: needs cross-ref | | Same-cwd + cross-ref (iTerm2) | TTY match via AppleScript | ✓ (with `/rename`) | | Same-cwd + cross-ref (cmux) | TTY match via tree --all | ✓ (with `/rename`) | -| Same-cwd + cross-ref (Ghostty) | No TTY → cwd fallback | May be wrong | +| Same-cwd + cross-ref (Ghostty) | Tip build: TTY finds the tab, then its title must contain the session title; without a title → cwd fallback. Stable 1.3.x: no TTY → cwd fallback | Correct on tip when the session has a title; otherwise may be wrong | | VS Code / Claude Desktop | `entrypoint` field | ✓ (new capability) | **Legacy (without `sessions/`):** @@ -525,7 +525,7 @@ When multiple sessions share the same project path, there are two separate conce | `-n "name"` / `--resume "title"` | Match against custom titles | All | | `claude -r` (picker) / bare `claude` | cwd fallback + cross-ref | iTerm2/cmux (with `/rename`) | -Cross-reference: match PID TTY against terminal tab TTYs (iTerm2: `tty of session` AppleScript; Terminal.app: `tty of tab` AppleScript; cmux: `tree --all` tty= field), then match tab name against custom titles. Requires `/rename`. Ghostty pending upstream TTY ([#11592](https://github.com/ghostty-org/ghostty/issues/11592)). +Cross-reference: match PID TTY against terminal tab TTYs (iTerm2: `tty of session` AppleScript; Terminal.app: `tty of tab` AppleScript; cmux: `tree --all` tty= field; Ghostty tip builds: `«class Gtty»` of each terminal), then match tab name against custom titles. Requires `/rename`. Ghostty pending upstream TTY ([#11592](https://github.com/ghostty-org/ghostty/issues/11592)). #### Switch layer (click → jump to correct tab) @@ -539,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 — 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. +**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 has the same TTY matching on tip builds (#63); stable Ghostty (1.3.1 and earlier) lacks a per-terminal TTY, so there a session without a custom title in a shared cwd still falls back to cwd matching and 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. @@ -611,7 +611,7 @@ Ghostty has full AppleScript support via `Ghostty.sdef`: **Note:** Ghostty CLI `+new-window` is not supported on macOS, but AppleScript `new window` works. The `.sdef` is similar to cmux's, but Ghostty's AppleScript actually works (cmux's `count windows` returns 0 — fix submitted as cmux PR #1826). -**Same-cwd limitation:** Without `/rename`, same-cwd sessions may switch to wrong tab. Ghostty does not expose per-terminal PID or TTY in AppleScript — upstream issues [#11592](https://github.com/ghostty-org/ghostty/issues/11592), [#10756](https://github.com/ghostty-org/ghostty/issues/10756), and PR [#11354](https://github.com/ghostty-org/ghostty/pull/11354) track adding this. +**Same-cwd limitation (stable Ghostty only):** Ghostty tip builds expose per-terminal `tty` and `pid` on the AppleScript `terminal` class (PR [#11922](https://github.com/ghostty-org/ghostty/pull/11922), merged; closes [#11592](https://github.com/ghostty-org/ghostty/issues/11592) and [#10756](https://github.com/ghostty-org/ghostty/issues/10756)), and CodeV switches by tty there (measured on tip build 18371, 2026-10-22: 16 terminals sharing one cwd, each reached its own tab). As of 2026-10-22 no release carries it yet (latest v1.3.1), so on stable Ghostty a same-cwd session without `/rename` may still switch to the wrong tab. ### Branch Name: Why Not `git branch --show-current` @@ -632,7 +632,7 @@ Ghostty has full AppleScript support via `Ghostty.sdef`: - Last assistant response for active sessions via `tail -n 200` - Open/resume in iTerm2, Ghostty, or cmux - iTerm2: three-layer switch (title → TTY → fallback) -- Ghostty: two-layer switch (title → cwd fallback) +- Ghostty: two-layer switch (title → cwd fallback) — superseded in 2026-10 by tty matching on Ghostty tip builds (#63) - cmux: two-layer switch (title → cwd fallback, surface-level) - Default Tab, Launch Terminal, Launch Mode, Session Preview settings - 1.5-3 line layout with color-coded elements @@ -655,7 +655,7 @@ Ghostty has full AppleScript support via `Ghostty.sdef`: - Session preview panel (conversation summary) - Cost/token statistics from `session-metadata.db` - Custom terminal command template -- Per-terminal PID/TTY matching (pending upstream: Ghostty #11592, cmux #1826) +- ~~Per-terminal PID/TTY matching~~ — done: cmux via `tree --all` (PR #64), Ghostty on tip builds (#63, 2026-10) ## Technical Decisions @@ -706,6 +706,7 @@ Current fix (PR #78): skip non-cli sessions in `detectActiveSessions()` to preve |------|------|-------|--------|----------------| | manaflow-ai/cmux | PR #1826 | Fix AppleScript `count windows` + `working directory` | Our PR, Open | Enables AppleScript for cmux (unified approach) | | manaflow-ai/cmux | PR #1287 | Add per-surface cwd to API | Open | Alternative to AppleScript for 2nd-tab matching | -| ghostty-org/ghostty | Issue #11592 | Add pid/tty to AppleScript terminal class | Open | Would fix same-cwd switching | -| ghostty-org/ghostty | PR #11354 | Expose PID/TTY on TerminalEntity | Open | Implementation in progress | -| ghostty-org/ghostty | Issue #10756 | Expose TTY/PID in App Shortcuts | Open | Related request | +| ghostty-org/ghostty | Issue #11592 | Add pid/tty to AppleScript terminal class | Closed | Fixed by PR #11922 | +| ghostty-org/ghostty | PR #11922 | Add pid and tty to the AppleScript terminal class and TerminalEntity | Merged (tip only; not in v1.3.1) | CodeV uses it for tty switching (#63) | +| ghostty-org/ghostty | PR #11354 | Expose PID/TTY on TerminalEntity | Closed | Superseded by #11922 | +| ghostty-org/ghostty | Issue #10756 | Expose TTY/PID in App Shortcuts | Closed | Covered by #11922 | diff --git a/package.json b/package.json index e115246..575c096 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.93", + "version": "1.0.94", "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 b8cb02f..c990848 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -42,10 +42,12 @@ import { sessionIdFromArgs, } from './live-sessions'; import { + buildGhosttySwitchScript, buildITerm2SwitchScript, buildTerminalAppSwitchScript, switchOrderFor, } from './terminal-switch'; +import { pickSessionByTabTitle } from './cross-ref-title'; export interface ClaudeSession { sessionId: string; @@ -742,11 +744,14 @@ end tell`); const sessionTitles = await getTitlesForCwd(item.cwd); const tabName = itermSession.name; - for (const [sessionId, title] of sessionTitles) { - if (title && tabName.includes(title) && !activeMap.has(sessionId)) { - activeMap.set(sessionId, item.pid); - break; - } + const matched = pickSessionByTabTitle( + tabName, + item.candidates.map(c => c.sessionId), + sessionTitles, + id => activeMap.has(id), + ); + if (matched) { + activeMap.set(matched, item.pid); } } })()); @@ -777,11 +782,14 @@ end tell`); const sessionTitles = await getTitlesForCwd(item.cwd); const tabName = cmuxSurface.title; - for (const [sessionId, title] of sessionTitles) { - if (title && tabName.includes(title) && !activeMap.has(sessionId)) { - activeMap.set(sessionId, item.pid); - break; - } + const matched = pickSessionByTabTitle( + tabName, + item.candidates.map(c => c.sessionId), + sessionTitles, + id => activeMap.has(id), + ); + if (matched) { + activeMap.set(matched, item.pid); } } })()); @@ -822,11 +830,76 @@ end tell`); const sessionTitles = await getTitlesForCwd(item.cwd); const tabName = termTab.title; - for (const [sessionId, title] of sessionTitles) { - if (title && tabName.includes(title) && !activeMap.has(sessionId)) { - activeMap.set(sessionId, item.pid); - break; - } + const matched = pickSessionByTabTitle( + tabName, + item.candidates.map(c => c.sessionId), + sessionTitles, + id => activeMap.has(id), + ); + if (matched) { + activeMap.set(matched, item.pid); + } + } + })()); + } + + // Ghostty: the same tty → tab title → custom title match, once its tip + // builds expose `tty` per terminal (#63, ghostty#11922). Unlike the three + // above, a Ghostty pid left unmatched still takes the cwd fallback below: + // stable Ghostty 1.3 has no `tty` (the `try` returns nothing), and it must + // not lose the only key it had before this cross-ref existed. + const ghosttyMatched = new Set(); + if (byTerminal['ghostty']?.length) { + crossRefTasks.push((async () => { + // Never `tell` Ghostty when it is not running — that would launch it. + // `ps`, not `pgrep`: its comm is the full bundle path, `pgrep -x ghostty` + // never matches, and `pgrep -f` saw nothing in a measured shell. + const ghosttyCheck = await execPromise("ps -A -o comm= | grep '/Ghostty.app/Contents/MacOS/ghostty$' 2>/dev/null"); + if (!ghosttyCheck.trim()) return; + const tmpScript = '/tmp/codev-ghostty-detect.scpt'; + // `«class Gtty»` rather than `tty`: compiles on a Ghostty whose + // dictionary lacks the word (see terminal-switch.ts). + fs.writeFileSync(tmpScript, `tell application "Ghostty" + set results to "" + try + repeat with w in windows + repeat with t in tabs of w + repeat with term in terminals of t + set results to results & («class Gtty» of term) & "|||" & (name of term) & linefeed + end repeat + end repeat + end repeat + end try + return results +end tell`); + const ghosttyOutput = await execPromise(`osascript ${tmpScript} 2>/dev/null`); + try { fs.unlinkSync(tmpScript); } catch {} + if (!ghosttyOutput.trim()) return; + + const ghosttyTerms: { tty: string; title: string }[] = []; + for (const line of ghosttyOutput.split('\n')) { + const parts = line.split('|||'); + if (parts.length === 2 && parts[0].trim()) { + ghosttyTerms.push({ tty: parts[0].trim(), title: parts[1].trim() }); + } + } + + for (const item of byTerminal['ghostty']) { + const ttyOutput = (await execPromise(`ps -o tty= -p ${item.pid} 2>/dev/null`)).trim(); + if (!ttyOutput) continue; + const term = ghosttyTerms.find(s => s.tty.endsWith(ttyOutput)); + if (!term) continue; + + const sessionTitles = await getTitlesForCwd(item.cwd); + const matched = pickSessionByTabTitle( + term.title, + item.candidates.map(c => c.sessionId), + sessionTitles, + id => activeMap.has(id), + ); + if (matched) { + activeMap.set(matched, item.pid); + ghosttyMatched.add(item.pid); } } })()); @@ -834,10 +907,12 @@ end tell`); await Promise.all(crossRefTasks); - // Ghostty + unknown terminals: cwd fallback (no async work, runs after cross-ref) + // Unknown terminals, and Ghostty pids its cross-ref did not place: cwd + // fallback (no async work, runs after cross-ref) for (const [terminal, items] of Object.entries(byTerminal)) { if (terminal === 'iterm2' || terminal === 'cmux' || terminal === 'terminal' || terminal === 'codev') continue; for (const item of items) { + if (terminal === 'ghostty' && ghosttyMatched.has(item.pid)) continue; const fallback = item.candidates.find(s => !activeMap.has(s.sessionId)); if (fallback) { activeMap.set(fallback.sessionId, item.pid); @@ -1445,6 +1520,7 @@ export const openSession = async ( sessionId, projectPath, isActive, + activePid, terminalMode, customTitle, accountLabel, @@ -2573,6 +2649,7 @@ export const openSessionInGhostty = async ( sessionId: string, projectPath: string, isActive: boolean, + activePid?: number, terminalMode: string = 'tab', customTitle?: string, accountLabel?: string, @@ -2580,44 +2657,19 @@ export const openSessionInGhostty = async ( const { exec } = require('child_process'); if (isActive) { - // Two-layer matching: title first (precise), then cwd fallback - const titleMatch = customTitle - ? ` - -- Layer 1: title matching - repeat with w in windows - repeat with t in tabs of w - repeat with term in terminals of t - if name of term contains "${customTitle.replace(/"/g, '\\"')}" then - focus term - 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), then cwd. tty needs a Ghostty tip build (#63); + // on stable Ghostty the tty block fails quietly and title/cwd run. + const order = switchOrderFor(!!activePid && !isGuessedPid(activePid)); const tmpScript = '/tmp/codev-ghostty-switch.scpt'; - const switchScript = `tell application "Ghostty" - activate - ${titleMatch} - -- Layer 2: cwd matching (fallback) - repeat with w in windows - repeat with t in tabs of w - repeat with term in terminals of t - if working directory of term is "${projectPath}" then - focus term - return "found-by-cwd" - end if - end repeat - end repeat - end repeat - return "not found" -end tell`; - console.log(`[ghostty] switch: customTitle=${customTitle || 'none'}`); + const switchScript = buildGhosttySwitchScript(activePid, customTitle, order, projectPath); + console.log( + `[ghostty] switch: pid=${activePid ?? 'none'}, order=${order}, customTitle=${customTitle || 'none'}`, + ); fs.writeFileSync(tmpScript, switchScript); exec(`osascript ${tmpScript}`, { encoding: 'utf-8', timeout: 5000 }, (error: any, stdout: string) => { const result = (stdout || '').trim(); - console.log('[ghostty] switch result:', result); + console.log('[ghostty] switch result:', result, error?.message || ''); if (result === 'not found') { copyResumeCommand(sessionId, projectPath, accountLabel); } diff --git a/src/cross-ref-title.test.ts b/src/cross-ref-title.test.ts new file mode 100644 index 0000000..dc7f910 --- /dev/null +++ b/src/cross-ref-title.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { pickSessionByTabTitle } from './cross-ref-title'; + +const titles = new Map([ + ['s-fix', 'Fix'], + ['s-fix-backend', 'Fix backend'], + ['s-other', 'Other'], +]); +const none = () => false; + +describe('pickSessionByTabTitle', () => { + it('takes the most specific title when one contains another', () => { + // The tab title contains both `Fix` and `Fix backend`; the old loop took + // whichever it met first. + expect( + pickSessionByTabTitle('✳ Fix backend', ['s-fix', 's-fix-backend'], titles, none), + ).toBe('s-fix-backend'); + expect( + pickSessionByTabTitle('✳ Fix backend', ['s-fix-backend', 's-fix'], titles, none), + ).toBe('s-fix-backend'); + }); + + it('only picks from the candidates, never another transcript in the directory', () => { + expect(pickSessionByTabTitle('✳ Fix backend', ['s-fix'], titles, none)).toBe('s-fix'); + expect(pickSessionByTabTitle('✳ Other', ['s-fix'], titles, none)).toBeUndefined(); + }); + + it('skips a session already placed on another process', () => { + expect( + pickSessionByTabTitle('✳ Fix backend', ['s-fix', 's-fix-backend'], titles, id => id === 's-fix-backend'), + ).toBe('s-fix'); + }); + + it('returns nothing for a candidate without a title or a tab that names no one', () => { + expect(pickSessionByTabTitle('anything', ['s-untitled'], titles, none)).toBeUndefined(); + expect(pickSessionByTabTitle('9e439ca9-5a3b-4e', ['s-fix', 's-other'], titles, none)).toBeUndefined(); + }); +}); diff --git a/src/cross-ref-title.ts b/src/cross-ref-title.ts new file mode 100644 index 0000000..b420995 --- /dev/null +++ b/src/cross-ref-title.ts @@ -0,0 +1,28 @@ +/** + * Detection's cross-reference, last step: a process's terminal tab has been + * found by tty; which session does that tab belong to? The tab title carries + * the session's `/rename` title, so pick the session whose title the tab + * title contains. + * + * One rule for every terminal (iTerm2, cmux, Terminal.app, Ghostty), which + * used to be four copies of a loop that took the FIRST title the tab + * contained, from every transcript in the directory. Two defects followed: + * a title that is a substring of another (`Fix` inside `Fix backend`) could + * claim the tab, and a session with no row in the list (absent from history) + * could claim it. So: only the queued candidates, and the LONGEST matching + * title, which is the most specific one. + */ +export const pickSessionByTabTitle = ( + tabTitle: string, + candidateIds: readonly string[], + titles: ReadonlyMap, + isTaken: (sessionId: string) => boolean, +): string | undefined => { + let best: { id: string; length: number } | undefined; + for (const id of candidateIds) { + const title = titles.get(id); + if (!title || isTaken(id) || !tabTitle.includes(title)) continue; + if (!best || title.length > best.length) best = { id, length: title.length }; + } + return best?.id; +}; diff --git a/src/terminal-switch.test.ts b/src/terminal-switch.test.ts index 5778081..abf8fda 100644 --- a/src/terminal-switch.test.ts +++ b/src/terminal-switch.test.ts @@ -1,14 +1,19 @@ import { describe, expect, it } from 'vitest'; import { + buildGhosttySwitchScript, buildITerm2SwitchScript, buildTerminalAppSwitchScript, escapeAppleScript, switchOrderFor, } from './terminal-switch'; +const ghostty = (pid: number, title: string | undefined, order: 'tty-first' | 'title-first') => + buildGhosttySwitchScript(pid, title, order, '/p'); + const builders = [ ['iTerm2', buildITerm2SwitchScript], ['Terminal.app', buildTerminalAppSwitchScript], + ['Ghostty', ghostty], ] as const; describe('switch script order follows pid provenance', () => { @@ -50,6 +55,42 @@ describe('switch script order follows pid provenance', () => { }); } + it('iTerm2 and Terminal.app get no cwd fallback — only Ghostty does', () => { + for (const build of [buildITerm2SwitchScript, buildTerminalAppSwitchScript]) { + expect(build(7, 'gg', 'tty-first')).not.toContain('found-by-cwd'); + } + }); + + it('Ghostty: cwd runs last, after tty and title, in either order', () => { + for (const order of ['tty-first', 'title-first'] as const) { + const script = ghostty(7, 'gg', order); + const cwd = script.indexOf('found-by-cwd'); + expect(cwd).toBeGreaterThan(script.indexOf('found-by-tty')); + expect(cwd).toBeGreaterThan(script.indexOf('found-by-title')); + } + }); + + it('Ghostty: with no pid there is no tty block, only title then cwd', () => { + const script = buildGhosttySwitchScript(undefined, 'gg', 'tty-first', '/p'); + expect(script).not.toContain('found-by-tty'); + expect(script).not.toContain('ps -o tty='); + expect(script.indexOf('found-by-title')).toBeLessThan(script.indexOf('found-by-cwd')); + }); + + it('Ghostty: the tty is read by raw code inside try, so stable Ghostty still compiles', () => { + // Stable 1.3's dictionary has no `tty`; a plain `tty of term` may fail to + // compile there and take title and cwd down with it. + const script = ghostty(7, undefined, 'tty-first'); + expect(script).toContain('«class Gtty» of term'); + expect(script).not.toMatch(/\btty of term\b/); + expect(script.indexOf('try')).toBeLessThan(script.indexOf('«class Gtty»')); + }); + + it('Ghostty: the project path is escaped for an AppleScript string literal', () => { + const script = buildGhosttySwitchScript(7, undefined, 'tty-first', '/a "b" \\c'); + expect(script).toContain('is "/a \\"b\\" \\\\c"'); + }); + 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 index cb8aaa3..588a206 100644 --- a/src/terminal-switch.ts +++ b/src/terminal-switch.ts @@ -1,6 +1,6 @@ /** - * The AppleScript that jumps to a running session's tab (iTerm2 and - * Terminal.app; the other terminals have their own paths). Two keys can + * The AppleScript that jumps to a running session's tab (iTerm2, + * Terminal.app and Ghostty; cmux has its own CLI path). Two keys can * find the tab, and the ORDER they are tried in is the whole point of this * module: * @@ -96,17 +96,74 @@ const TERMINAL_TITLE = ( end repeat end repeat`; +// Ghostty exposes `tty` on its terminal class only from tip builds after +// ghostty#11922 (stable 1.3 has none). Two guards, because AppleScript +// resolves terminology against the app's dictionary at COMPILE time: +// `«class Gtty»` is the raw four-char code of that property, so the script +// still compiles where the dictionary lacks the word `tty`; and `try` turns +// the resulting runtime error on such a Ghostty into "no match", so title +// and cwd still run. Verified on tip build 18371: `«class Gtty»` returns the +// same `/dev/ttysNNN` as `tty`. +const GHOSTTY_TTY = ( + pid: number, +): string => ` -- tty matching (exact when the pid is; Ghostty tip only) + ${TTY_LOOKUP(pid)} + if targetTty is not "" then + try + repeat with w in windows + repeat with t in tabs of w + repeat with term in terminals of t + if («class Gtty» of term) ends with targetTty then + focus term + return "found-by-tty" + end if + end repeat + end repeat + end repeat + end try + end if`; + +const GHOSTTY_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 term in terminals of t + if name of term contains "${escapeAppleScript(title)}" then + focus term + return "found-by-title" + end if + end repeat + end repeat + end repeat`; + +// Last resort, Ghostty only: the first terminal sitting in the project +// directory. Wrong whenever two sessions share a cwd — which is why tty and +// title run first. +const GHOSTTY_CWD = ( + projectPath: string, +): string => ` -- cwd matching (last resort: the first terminal in that directory) + repeat with w in windows + repeat with t in tabs of w + repeat with term in terminals of t + if working directory of term is "${escapeAppleScript(projectPath)}" then + focus term + return "found-by-cwd" + end if + end repeat + end repeat + end repeat`; + const assemble = ( - app: 'iTerm2' | 'Terminal', - tty: string, + app: 'iTerm2' | 'Terminal' | 'Ghostty', + tty: string | undefined, title: string | undefined, order: SwitchOrder, + fallback?: string, ): string => { - const blocks = title - ? order === 'tty-first' - ? [tty, title] - : [title, tty] - : [tty]; + const keyed = + order === 'tty-first' ? [tty, title] : [title, tty]; + const blocks = [...keyed, fallback].filter((b): b is string => !!b); return `tell application "${app}" activate ${blocks.join('\n')} @@ -126,6 +183,25 @@ export const buildITerm2SwitchScript = ( order, ); +/** + * Ghostty: tty and title in the provenance order, then cwd as a last resort. + * `pid` is optional — a session with no known process still gets title and + * cwd, which is all Ghostty could do before its tip builds exposed `tty`. + */ +export const buildGhosttySwitchScript = ( + pid: number | undefined, + customTitle: string | undefined, + order: SwitchOrder, + projectPath: string, +): string => + assemble( + 'Ghostty', + pid ? GHOSTTY_TTY(pid) : undefined, + customTitle ? GHOSTTY_TITLE(customTitle) : undefined, + order, + GHOSTTY_CWD(projectPath), + ); + export const buildTerminalAppSwitchScript = ( pid: number, customTitle: string | undefined,