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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user