Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/server/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
42 changes: 42 additions & 0 deletions packages/server/src/foreground-process-title.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
154 changes: 154 additions & 0 deletions packages/server/src/foreground-process-title.ts
Original file line number Diff line number Diff line change
@@ -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<number, ProcessInfo[]>,
): 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<number, ProcessInfo[]>();
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<string | null> => {
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;
}
};
54 changes: 51 additions & 3 deletions packages/server/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -47,6 +51,9 @@ export class Session extends EventEmitter<SessionEvents> {
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();
Expand All @@ -57,7 +64,7 @@ export class Session extends EventEmitter<SessionEvents> {
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,
Expand All @@ -69,10 +76,10 @@ export class Session extends EventEmitter<SessionEvents> {
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<string, string> = {};
Expand Down Expand Up @@ -114,8 +121,12 @@ export class Session extends EventEmitter<SessionEvents> {
this.pty.onExit(({ exitCode }) => {
this.exited = true;
this.exitCode = exitCode;
this.stopTitlePolling();
this.emit("exit", exitCode);
});

this.startTitlePolling();
void this.refreshProcessTitle();
}

get pid(): number {
Expand Down Expand Up @@ -151,6 +162,42 @@ export class Session extends EventEmitter<SessionEvents> {
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<void> {
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,
Expand Down Expand Up @@ -205,6 +252,7 @@ export class Session extends EventEmitter<SessionEvents> {
}

dispose(): void {
this.stopTitlePolling();
this.kill();
this.headless.dispose();
this.removeAllListeners();
Expand Down
23 changes: 23 additions & 0 deletions packages/server/src/working-directory-title.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
26 changes: 26 additions & 0 deletions packages/server/src/working-directory-title.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
};
Loading