diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 402eb397e..dcc63b821 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 12ee857f6..55c990497 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -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; diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index c97fb2da2..39dd0b181 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -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 `---*--` 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(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 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4871a4359..7a0794ce3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 4bb911dc6..0b850aab9 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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"; diff --git a/packages/tui/src/ttyid.ts b/packages/tui/src/ttyid.ts new file mode 100644 index 000000000..313d5cfee --- /dev/null +++ b/packages/tui/src/ttyid.ts @@ -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; +} diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 0fdfeadf7..313057159 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -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); }