From 0961a7211b67903dfa2f227088af7388d667bad2 Mon Sep 17 00:00:00 2001 From: Ben MacLaurin Date: Sun, 3 May 2026 05:51:21 -0400 Subject: [PATCH] feat: mirror Ghostty-style terminal titles Co-authored-by: Cursor --- packages/server/src/constants.ts | 4 + .../src/foreground-process-title.test.ts | 42 +++++ .../server/src/foreground-process-title.ts | 154 ++++++++++++++++++ packages/server/src/session.ts | 54 +++++- .../src/working-directory-title.test.ts | 23 +++ .../server/src/working-directory-title.ts | 26 +++ 6 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/foreground-process-title.test.ts create mode 100644 packages/server/src/foreground-process-title.ts create mode 100644 packages/server/src/working-directory-title.test.ts create mode 100644 packages/server/src/working-directory-title.ts diff --git a/packages/server/src/constants.ts b/packages/server/src/constants.ts index e618ca7..5fac81b 100644 --- a/packages/server/src/constants.ts +++ b/packages/server/src/constants.ts @@ -5,6 +5,10 @@ export const DEFAULT_ROWS = 32; export const DEFAULT_SCROLLBACK = 5000; export const DEFAULT_SHELL_FALLBACK = "/bin/sh"; export const DEFAULT_TITLE = "shell"; +export const PROCESS_TITLE_POLL_MS = 500; +export const PROCESS_TITLE_RESOLVE_TIMEOUT_MS = 250; +export const IDLE_TITLE_MAX_PATH_SEGMENTS = 3; +export const IDLE_TITLE_TRUNCATION_PREFIX = "…"; export const FRIENDLY_ID_SUFFIX_LENGTH = 4; diff --git a/packages/server/src/foreground-process-title.test.ts b/packages/server/src/foreground-process-title.test.ts new file mode 100644 index 0000000..7ad7ea5 --- /dev/null +++ b/packages/server/src/foreground-process-title.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + parseProcessTable, + selectForegroundProcessTitle, + titleFromCommand, +} from "./foreground-process-title.js"; + +describe("foreground process title", () => { + it("uses the foreground process group leader", () => { + const processes = parseProcessTable(` + 10 1 10 20 Ss /bin/zsh + 20 10 20 20 S+ /usr/bin/vim + 21 20 20 20 S+ /usr/bin/helper + `); + + expect(selectForegroundProcessTitle(processes, 10)).toBe("vim"); + }); + + it("returns null when the shell owns the foreground process group", () => { + const processes = parseProcessTable(` + 10 1 10 10 Ss /bin/zsh + 20 10 20 10 S /usr/bin/sleep + `); + + expect(selectForegroundProcessTitle(processes, 10)).toBeNull(); + }); + + it("uses a descendant process group when tpgid is unavailable", () => { + const processes = parseProcessTable(` + 10 1 10 0 Ss /bin/zsh + 20 10 20 0 S+ /opt/homebrew/bin/pnpm + 21 20 20 0 S+ /usr/local/bin/node + `); + + expect(selectForegroundProcessTitle(processes, 10)).toBe("pnpm"); + }); + + it("normalizes command paths into display titles", () => { + expect(titleFromCommand("/opt/homebrew/bin/htop")).toBe("htop"); + expect(titleFromCommand("")).toBeNull(); + }); +}); diff --git a/packages/server/src/foreground-process-title.ts b/packages/server/src/foreground-process-title.ts new file mode 100644 index 0000000..14f3a3a --- /dev/null +++ b/packages/server/src/foreground-process-title.ts @@ -0,0 +1,154 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; +import { PROCESS_TITLE_RESOLVE_TIMEOUT_MS } from "./constants.js"; + +const execFileAsync = promisify(execFile); + +interface ProcessInfo { + pid: number; + parentPid: number; + processGroupId: number; + terminalProcessGroupId: number; + state: string; + command: string; +} + +interface ProcessCandidate { + process: ProcessInfo; + depth: number; +} + +const parseNumber = (raw: string): number | null => { + const value = Number(raw); + if (!Number.isInteger(value)) return null; + return value; +}; + +const parsePsLine = (line: string): ProcessInfo | null => { + const match = line + .trim() + .match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(.+)$/); + if (!match) return null; + const [, rawPid, rawParentPid, rawProcessGroupId, rawTerminalProcessGroupId, state, command] = + match; + const pid = parseNumber(rawPid); + const parentPid = parseNumber(rawParentPid); + const processGroupId = parseNumber(rawProcessGroupId); + const terminalProcessGroupId = parseNumber(rawTerminalProcessGroupId); + if ( + pid === null || + parentPid === null || + processGroupId === null || + terminalProcessGroupId === null || + state === undefined || + command === undefined + ) { + return null; + } + return { + pid, + parentPid, + processGroupId, + terminalProcessGroupId, + state, + command, + }; +}; + +export const parseProcessTable = (stdout: string): ProcessInfo[] => + stdout + .split("\n") + .map(parsePsLine) + .filter((processInfo) => processInfo !== null); + +export const titleFromCommand = (command: string): string | null => { + const trimmed = command.trim(); + if (!trimmed) return null; + const title = path.basename(trimmed); + return title || trimmed; +}; + +const isLiveProcess = (processInfo: ProcessInfo): boolean => !processInfo.state.includes("Z"); + +const collectProcessCandidates = ( + rootPid: number, + byParentPid: Map, +): ProcessCandidate[] => { + const candidates: ProcessCandidate[] = []; + const stack: ProcessCandidate[] = (byParentPid.get(rootPid) ?? []).map((processInfo) => ({ + process: processInfo, + depth: 1, + })); + while (stack.length > 0) { + const candidate = stack.pop(); + if (!candidate) continue; + candidates.push(candidate); + const children = byParentPid.get(candidate.process.pid) ?? []; + for (const child of children) { + stack.push({ process: child, depth: candidate.depth + 1 }); + } + } + return candidates; +}; + +const compareByDepthThenPid = (left: ProcessCandidate, right: ProcessCandidate): number => + left.depth - right.depth || left.process.pid - right.process.pid; + +const selectGroupLeader = (candidates: ProcessCandidate[]): ProcessInfo | null => { + const sorted = candidates.toSorted(compareByDepthThenPid); + const leader = sorted.find((candidate) => candidate.process.pid === candidate.process.processGroupId); + return (leader ?? sorted[0])?.process ?? null; +}; + +export const selectForegroundProcessTitle = ( + processes: ProcessInfo[], + rootPid: number, +): string | null => { + const byPid = new Map(processes.map((processInfo) => [processInfo.pid, processInfo])); + const root = byPid.get(rootPid); + if (!root || !isLiveProcess(root)) return null; + + const byParentPid = new Map(); + for (const processInfo of processes) { + const siblings = byParentPid.get(processInfo.parentPid) ?? []; + siblings.push(processInfo); + byParentPid.set(processInfo.parentPid, siblings); + } + + const descendants = collectProcessCandidates(rootPid, byParentPid).filter((candidate) => + isLiveProcess(candidate.process), + ); + const rootCandidate: ProcessCandidate = { process: root, depth: 0 }; + const candidates = [rootCandidate, ...descendants]; + + if (root.terminalProcessGroupId > 0) { + if (root.terminalProcessGroupId === root.processGroupId) return null; + const foregroundCandidates = candidates.filter( + (candidate) => candidate.process.processGroupId === root.terminalProcessGroupId, + ); + const foregroundProcess = selectGroupLeader(foregroundCandidates); + if (foregroundProcess) return titleFromCommand(foregroundProcess.command); + } + + const nonShellGroupCandidates = descendants.filter( + (candidate) => candidate.process.processGroupId !== root.processGroupId, + ); + const descendantProcess = selectGroupLeader(nonShellGroupCandidates); + return descendantProcess ? titleFromCommand(descendantProcess.command) : null; +}; + +export const resolveForegroundProcessTitle = async (rootPid: number): Promise => { + if (!Number.isFinite(rootPid) || rootPid <= 0) return null; + if (process.platform === "win32") return null; + try { + const { stdout } = await execFileAsync( + "ps", + ["-axo", "pid=,ppid=,pgid=,tpgid=,stat=,comm="], + { timeout: PROCESS_TITLE_RESOLVE_TIMEOUT_MS, windowsHide: true }, + ); + return selectForegroundProcessTitle(parseProcessTable(stdout), rootPid); + } catch { + return null; + } +}; diff --git a/packages/server/src/session.ts b/packages/server/src/session.ts index c4feb53..d314260 100644 --- a/packages/server/src/session.ts +++ b/packages/server/src/session.ts @@ -9,11 +9,15 @@ import { DEFAULT_ROWS, DEFAULT_SCROLLBACK, DEFAULT_TITLE, + PROCESS_TITLE_POLL_MS, PTY_ENV_DENYLIST, } from "./constants.js"; import { ensureSpawnHelperExecutable } from "./ensure-spawn-helper-executable.js"; import { generateFriendlyId } from "./friendly-id.js"; +import { resolveForegroundProcessTitle } from "./foreground-process-title.js"; import { getDefaultShell } from "./default-shell.js"; +import { resolveCwdForPid } from "./cwd-resolver.js"; +import { formatWorkingDirectoryTitle } from "./working-directory-title.js"; import type { CreateSessionInput, ServerToClientMessage, SessionMetadata } from "./types.js"; const requireCjs = createRequire(import.meta.url); @@ -47,6 +51,9 @@ export class Session extends EventEmitter { private exited = false; private exitCode: number | null = null; private attachmentCount = 0; + private titlePollTimer: NodeJS.Timeout | null = null; + private titlePollPending = false; + private hasResolvedAutomaticTitle = false; constructor(input: CreateSessionInput) { super(); @@ -57,7 +64,7 @@ export class Session extends EventEmitter { this.currentCols = input.cols ?? DEFAULT_COLS; this.currentRows = input.rows ?? DEFAULT_ROWS; this.createdAt = Date.now(); - this.currentTitle = DEFAULT_TITLE; + this.currentTitle = formatWorkingDirectoryTitle(this.cwd) || DEFAULT_TITLE; this.headless = new HeadlessTerminalCtor({ cols: this.currentCols, @@ -69,10 +76,10 @@ export class Session extends EventEmitter { this.headless.loadAddon(this.serialize); this.headless.onTitleChange((title) => { + if (this.hasResolvedAutomaticTitle) return; const trimmed = title.trim(); if (!trimmed || trimmed === this.currentTitle) return; - this.currentTitle = trimmed; - this.emit("title", trimmed); + this.setTitle(trimmed); }); const env: Record = {}; @@ -114,8 +121,12 @@ export class Session extends EventEmitter { this.pty.onExit(({ exitCode }) => { this.exited = true; this.exitCode = exitCode; + this.stopTitlePolling(); this.emit("exit", exitCode); }); + + this.startTitlePolling(); + void this.refreshProcessTitle(); } get pid(): number { @@ -151,6 +162,42 @@ export class Session extends EventEmitter { this.attachmentCount -= 1; } + private setTitle(title: string): void { + if (title === this.currentTitle) return; + this.currentTitle = title; + this.emit("title", title); + } + + private startTitlePolling(): void { + if (this.titlePollTimer) return; + this.titlePollTimer = setInterval(() => void this.refreshProcessTitle(), PROCESS_TITLE_POLL_MS); + this.titlePollTimer.unref(); + } + + private stopTitlePolling(): void { + if (!this.titlePollTimer) return; + clearInterval(this.titlePollTimer); + this.titlePollTimer = null; + } + + private async refreshProcessTitle(): Promise { + if (this.exited || this.titlePollPending) return; + this.titlePollPending = true; + try { + const foregroundTitle = await resolveForegroundProcessTitle(this.pid); + if (foregroundTitle) { + this.hasResolvedAutomaticTitle = true; + this.setTitle(foregroundTitle); + return; + } + const cwd = await resolveCwdForPid(this.pid); + this.hasResolvedAutomaticTitle = true; + this.setTitle(formatWorkingDirectoryTitle(cwd ?? this.cwd)); + } finally { + this.titlePollPending = false; + } + } + metadata(): SessionMetadata { return { id: this.id, @@ -205,6 +252,7 @@ export class Session extends EventEmitter { } dispose(): void { + this.stopTitlePolling(); this.kill(); this.headless.dispose(); this.removeAllListeners(); diff --git a/packages/server/src/working-directory-title.test.ts b/packages/server/src/working-directory-title.test.ts new file mode 100644 index 0000000..d57b164 --- /dev/null +++ b/packages/server/src/working-directory-title.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { formatWorkingDirectoryTitle } from "./working-directory-title.js"; + +describe("formatWorkingDirectoryTitle", () => { + const home = "/Users/tester"; + + it("uses zsh-style home abbreviation", () => { + expect(formatWorkingDirectoryTitle(home, home)).toBe("~"); + expect(formatWorkingDirectoryTitle("/Users/tester/Developer/localterm", home)).toBe( + "~/Developer/localterm", + ); + }); + + it("keeps paths with three or fewer display segments whole", () => { + expect(formatWorkingDirectoryTitle("/usr/local/bin", home)).toBe("/usr/local/bin"); + }); + + it("truncates deep paths to the last three display segments", () => { + expect(formatWorkingDirectoryTitle("/Users/tester/Developer/localterm/packages/server", home)).toBe( + "…/localterm/packages/server", + ); + }); +}); diff --git a/packages/server/src/working-directory-title.ts b/packages/server/src/working-directory-title.ts new file mode 100644 index 0000000..0f96f23 --- /dev/null +++ b/packages/server/src/working-directory-title.ts @@ -0,0 +1,26 @@ +import os from "node:os"; +import path from "node:path"; +import { IDLE_TITLE_MAX_PATH_SEGMENTS, IDLE_TITLE_TRUNCATION_PREFIX } from "./constants.js"; + +const HOME_PREFIX = "~"; +const PATH_SEPARATOR = "/"; + +export const abbreviateHome = (cwd: string, home = os.homedir()): string => { + if (!cwd) return cwd; + const normalizedCwd = path.resolve(cwd); + const normalizedHome = path.resolve(home); + if (normalizedCwd === normalizedHome) return HOME_PREFIX; + if (normalizedCwd.startsWith(`${normalizedHome}${PATH_SEPARATOR}`)) { + return `${HOME_PREFIX}${normalizedCwd.slice(normalizedHome.length)}`; + } + return normalizedCwd; +}; + +export const formatWorkingDirectoryTitle = (cwd: string, home = os.homedir()): string => { + const abbreviated = abbreviateHome(cwd, home); + const segments = abbreviated.split(PATH_SEPARATOR).filter(Boolean); + if (segments.length <= IDLE_TITLE_MAX_PATH_SEGMENTS) return abbreviated; + return `${IDLE_TITLE_TRUNCATION_PREFIX}${PATH_SEPARATOR}${segments + .slice(-IDLE_TITLE_MAX_PATH_SEGMENTS) + .join(PATH_SEPARATOR)}`; +};