feat: exported terminal ID utilities and improved session directory naming

- Exported `getTerminalId()` and `getTtyPath()` functions from tui package for obtaining stable terminal identifiers with support for TTY device paths and terminal multiplexers.
- Improved path display in status line to strip both `/work/` and `~/Projects/` prefixes when abbreviating paths.
- Refactored session directory naming to use single-dash format for home-relative paths and double-dash format for absolute paths, with automatic migration of legacy session directories on first access.
- Implemented TTY ID utilities in tui package with platform-specific TTY path resolution using /proc/self/fd/0 on Linux, dlopen on macOS/BSD, and libc on other Unix systems.
- Added macOS path standardization to strip `/private` prefix from paths when both original and stripped paths resolve to the same location.
This commit is contained in:
can1357
2026-02-17 09:23:30 +01:00
parent cd4b3ec753
commit 25231d0723
7 changed files with 175 additions and 45 deletions
+4
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Changed
- Improved path display in status line to strip both `/work/` and `~/Projects/` prefixes when abbreviating paths
- Refactored session directory naming to use single-dash format for home-relative paths and double-dash format for absolute paths, with automatic migration of legacy session directories on first access
## [12.8.2] - 2026-02-17
### Changed
@@ -97,8 +97,9 @@ const pathSegment: StatusLineSegment = {
if (opts.abbreviate !== false) {
pwd = shortenPath(pwd);
}
if (opts.stripWorkPrefix !== false && pwd.startsWith("/work/")) {
pwd = pwd.slice(6);
if (opts.stripWorkPrefix !== false) {
if (pwd.startsWith("/work/")) pwd = pwd.slice(6);
else if (pwd.startsWith("~/Projects/")) pwd = pwd.slice(11);
}
const maxLen = opts.maxLength ?? 40;
@@ -1,7 +1,9 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
import { getTerminalId } from "@oh-my-pi/pi-tui";
import { isEnoent, logger, parseJsonlLenient, Snowflake } from "@oh-my-pi/pi-utils";
import { getBlobsDir, getAgentDir as getDefaultAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { type BlobPutResult, BlobStore, externalizeImageData, isBlobRef, resolveImageData } from "./blob-store";
@@ -302,6 +304,67 @@ export function migrateSessionEntries(entries: FileEntry[]): void {
migrateToCurrentVersion(entries);
}
let sessionDirsMigrated = false;
/**
* Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
* Runs once on first access, best-effort.
*/
function migrateHomeSessionDirs(): void {
if (sessionDirsMigrated) return;
sessionDirsMigrated = true;
const home = os.homedir();
const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
const oldPrefix = `--${homeEncoded}-`;
const oldExact = `--${homeEncoded}--`;
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
let entries: string[];
try {
entries = fs.readdirSync(sessionsRoot);
} catch {
return;
}
for (const entry of entries) {
let remainder: string;
if (entry === oldExact) {
remainder = "";
} else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
remainder = entry.slice(oldPrefix.length, -2);
} else {
continue;
}
const newName = `-${remainder}`;
const oldPath = path.join(sessionsRoot, entry);
const newPath = path.join(sessionsRoot, newName);
try {
const existing = fs.statSync(newPath, { throwIfNoEntry: false });
if (existing?.isDirectory()) {
// Merge files from old dir into existing new dir
for (const file of fs.readdirSync(oldPath)) {
const src = path.join(oldPath, file);
const dst = path.join(newPath, file);
if (!fs.existsSync(dst)) {
fs.renameSync(src, dst);
}
}
fs.rmSync(oldPath, { recursive: true, force: true });
} else {
if (existing) {
fs.rmSync(newPath, { recursive: true, force: true });
}
fs.renameSync(oldPath, newPath);
}
} catch {
// Best effort
}
}
}
/** Exported for compaction.test.ts */
export function parseSessionEntries(content: string): FileEntry[] {
return parseJsonlLenient<FileEntry>(content);
@@ -459,13 +522,27 @@ export function buildSessionContext(
return { messages, thinkingLevel, models, injectedTtsrRules, mode, modeData };
}
/**
* Encode a cwd into a safe directory name for session storage.
* Home-relative paths use single-dash format: `/Users/x/Projects/pi` → `-Projects-pi`
* Absolute paths use double-dash format: `/tmp/foo` → `--tmp-foo--`
*/
function encodeSessionDirName(cwd: string): string {
const home = os.homedir();
if (cwd === home || cwd.startsWith(`${home}/`) || cwd.startsWith(`${home}\\`)) {
const relative = cwd.slice(home.length).replace(/^[/\\]/, "");
return `-${relative.replace(/[/\\:]/g, "-")}`;
}
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
/**
* Compute the default session directory for a cwd.
* Encodes cwd into a safe directory name under ~/.omp/agent/sessions/.
*/
function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = path.join(getDefaultAgentDir(), "sessions", safePath);
migrateHomeSessionDirs();
const dirName = encodeSessionDirName(cwd);
const sessionDir = path.join(getDefaultAgentDir(), "sessions", dirName);
storage.ensureDirSync(sessionDir);
return sessionDir;
}
@@ -476,40 +553,6 @@ function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
const TERMINAL_SESSIONS_DIR = "terminal-sessions";
/**
* Get a stable identifier for the current terminal.
* Uses the TTY device path (e.g., /dev/pts/3), falling back to environment
* variables for terminal multiplexers or terminal emulators.
* Returns null if no terminal can be identified (e.g., piped input).
*/
function getTerminalId(): string | null {
// TTY device path — most reliable, unique per terminal tab
if (process.stdin.isTTY) {
try {
// On Linux/macOS, /proc/self/fd/0 -> /dev/pts/N
const ttyPath = fs.readlinkSync("/proc/self/fd/0");
if (ttyPath.startsWith("/dev/")) {
return ttyPath.slice(5).replace(/\//g, "-"); // /dev/pts/3 -> pts-3
}
} catch {}
}
// Fallback to terminal-specific env vars
const kittyId = process.env.KITTY_WINDOW_ID;
if (kittyId) return `kitty-${kittyId}`;
const tmuxPane = process.env.TMUX_PANE;
if (tmuxPane) return `tmux-${tmuxPane}`;
const terminalSessionId = process.env.TERM_SESSION_ID; // macOS Terminal.app
if (terminalSessionId) return `apple-${terminalSessionId}`;
const wtSession = process.env.WT_SESSION; // Windows Terminal
if (wtSession) return `wt-${wtSession}`;
return null;
}
/**
* Write a breadcrumb linking the current terminal to a session file.
* The breadcrumb contains the cwd and session path so --continue can
+4
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Added
- Exported `getTerminalId()` function to get a stable identifier for the current terminal, with support for TTY device paths and terminal multiplexers
- Exported `getTtyPath()` function to resolve the TTY device path for stdin via POSIX `ttyname(3)`
## [12.5.0] - 2026-02-15
### Added
+2
View File
@@ -60,6 +60,8 @@ export type { BoxSymbols, SymbolTheme } from "./symbols";
export { emergencyTerminalRestore, ProcessTerminal, type Terminal } from "./terminal";
// Terminal image support
export * from "./terminal-capabilities";
// TTY ID
export { getTerminalId, getTtyPath } from "./ttyid";
export { type Component, Container, type OverlayHandle, type SizeValue, TUI } from "./tui";
// Utilities
export { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils";
+66
View File
@@ -0,0 +1,66 @@
import { CString, dlopen, FFIType } from "bun:ffi";
import * as fs from "node:fs";
import * as os from "node:os";
/** Resolve the TTY device path for stdin (fd 0) via POSIX `ttyname(3)`. */
export function getTtyPath(): string | null {
if (os.platform() === "linux") {
// Linux: /proc/self/fd/0 is a symlink to /dev/pts/N
try {
const ttyPath = fs.readlinkSync("/proc/self/fd/0");
if (ttyPath.startsWith("/dev/")) {
return ttyPath;
}
} catch {
return null;
}
} else if (os.platform() !== "win32") {
try {
const libName = os.platform() === "darwin" ? "libSystem.B.dylib" : "libc.so.6";
const lib = dlopen(libName, {
ttyname: { args: [FFIType.i32], returns: FFIType.ptr },
});
try {
const result = lib.symbols.ttyname(0);
return result ? new CString(result).toString() : null;
} finally {
lib.close();
}
} catch {
return null;
}
}
return null;
}
/**
* Get a stable identifier for the current terminal.
* Uses the TTY device path (e.g., /dev/pts/3), falling back to environment
* variables for terminal multiplexers or terminal emulators.
* Returns null if no terminal can be identified (e.g., piped input).
*/
export function getTerminalId(): string | null {
// TTY device path — most reliable, unique per terminal tab
if (process.stdin.isTTY) {
try {
const ttyPath = getTtyPath();
if (ttyPath?.startsWith("/dev/")) {
return ttyPath.slice(5).replace(/\//g, "-"); // /dev/pts/3 -> pts-3
}
} catch {}
}
// Fallback to terminal-specific env vars
const kittyId = process.env.KITTY_WINDOW_ID;
if (kittyId) return `kitty-${kittyId}`;
const tmuxPane = process.env.TMUX_PANE;
if (tmuxPane) return `tmux-${tmuxPane}`;
const terminalSessionId = process.env.TERM_SESSION_ID; // macOS Terminal.app
if (terminalSessionId) return `apple-${terminalSessionId}`;
const wtSession = process.env.WT_SESSION; // Windows Terminal
if (wtSession) return `wt-${wtSession}`;
return null;
}
+17 -7
View File
@@ -5,7 +5,7 @@
* PI_CODING_AGENT_DIR to override the agent directory.
*/
import { realpathSync } from "node:fs";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { version } from "../package.json" with { type: "json" };
@@ -24,13 +24,23 @@ export const VERSION: string = version;
// Root directories
// =============================================================================
let projectDir = process.cwd();
if ($env.PWD) {
if (realpathSync($env.PWD) === projectDir) {
projectDir = $env.PWD;
}
/**
* On macOS, strip /private prefix only when both paths resolve to the same location.
* This preserves aliases like /private/tmp -> /tmp without rewriting unrelated paths.
*/
function standardizeMacOSPath(p: string): string {
if (process.platform !== "darwin" || !p.startsWith("/private/")) return p;
const stripped = p.slice("/private".length);
try {
if (fs.realpathSync(p) === fs.realpathSync(stripped)) {
return stripped;
}
} catch {}
return p;
}
let projectDir = standardizeMacOSPath(process.cwd());
/** Get the project directory. */
export function getProjectDir(): string {
return projectDir;
@@ -38,7 +48,7 @@ export function getProjectDir(): string {
/** Set the project directory. */
export function setProjectDir(dir: string): void {
projectDir = path.resolve(dir);
projectDir = standardizeMacOSPath(path.resolve(dir));
process.chdir(projectDir);
}