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,