diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 520c75848..6aa1baf21 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -2,6 +2,17 @@
## [Unreleased]
+### Added
+
+- Added `/rename
` slash command to set an explicit session name, updating the session header and terminal tab title ([#658](https://github.com/can1357/oh-my-pi/issues/658))
+- Added `session_name` status line segment: displays the session name in the status bar right side with a stable hash-derived accent color unique to each name; shown in all presets when a name is set
+
+### Fixed
+
+- Session name sanitization: strip C0/C1 control characters (including ANSI ESC) from session names at storage time and in status line rendering, preventing escape sequence injection into TUI output
+- Auto-generated session titles no longer overwrite a name set via `/rename`: `setSessionName` now tracks whether the name was set by the user or auto-generated and silently ignores auto titles once a user name is in place; terminal title follows the same guard
+- Session accent border color now applied on session resume and after auto-title generation, not only after an explicit `/rename`
+
## [14.1.0] - 2026-04-11
### Added
diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts
index 25c973c05..7acb566e5 100644
--- a/packages/coding-agent/src/config/settings-schema.ts
+++ b/packages/coding-agent/src/config/settings-schema.ts
@@ -74,7 +74,8 @@ export type StatusLineSegmentId =
| "session"
| "hostname"
| "cache_read"
- | "cache_write";
+ | "cache_write"
+ | "session_name";
interface UiMetadata {
tab: SettingTab;
diff --git a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts
index f30050c8f..4410b8ff3 100644
--- a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts
+++ b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts
@@ -36,6 +36,7 @@ const SEGMENT_INFO: Record = {
default: {
leftSegments: ["pi", "model", "plan_mode", "path", "git", "pr", "context_pct", "token_total", "cost"],
- rightSegments: [],
+ rightSegments: ["session_name"],
separator: "powerline-thin",
segmentOptions: {
model: { showThinkingLevel: true },
@@ -14,7 +14,7 @@ export const STATUS_LINE_PRESETS: Record = {
minimal: {
leftSegments: ["path", "git"],
- rightSegments: ["plan_mode", "context_pct"],
+ rightSegments: ["session_name", "plan_mode", "context_pct"],
separator: "slash",
segmentOptions: {
path: { abbreviate: true, maxLength: 30 },
@@ -24,7 +24,7 @@ export const STATUS_LINE_PRESETS: Record = {
compact: {
leftSegments: ["model", "plan_mode", "git", "pr"],
- rightSegments: ["cost", "context_pct"],
+ rightSegments: ["session_name", "cost", "context_pct"],
separator: "powerline-thin",
segmentOptions: {
model: { showThinkingLevel: false },
@@ -34,7 +34,17 @@ export const STATUS_LINE_PRESETS: Record = {
full: {
leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "pr", "subagents"],
- rightSegments: ["token_in", "token_out", "token_rate", "cache_read", "cost", "context_pct", "time_spent", "time"],
+ rightSegments: [
+ "session_name",
+ "token_in",
+ "token_out",
+ "token_rate",
+ "cache_read",
+ "cost",
+ "context_pct",
+ "time_spent",
+ "time",
+ ],
separator: "powerline",
segmentOptions: {
model: { showThinkingLevel: true },
@@ -48,6 +58,7 @@ export const STATUS_LINE_PRESETS: Record = {
// Full preset with all Nerd Font icons
leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "pr", "session", "subagents"],
rightSegments: [
+ "session_name",
"token_in",
"token_out",
"cache_read",
@@ -71,7 +82,7 @@ export const STATUS_LINE_PRESETS: Record = {
ascii: {
// No Nerd Font dependencies
leftSegments: ["model", "plan_mode", "path", "git", "pr"],
- rightSegments: ["token_total", "cost", "context_pct"],
+ rightSegments: ["session_name", "token_total", "cost", "context_pct"],
separator: "ascii",
segmentOptions: {
model: { showThinkingLevel: true },
@@ -83,7 +94,7 @@ export const STATUS_LINE_PRESETS: Record = {
custom: {
// User-defined - these are just defaults that get overridden
leftSegments: ["model", "plan_mode", "path", "git", "pr"],
- rightSegments: ["token_total", "cost", "context_pct"],
+ rightSegments: ["session_name", "token_total", "cost", "context_pct"],
separator: "powerline-thin",
segmentOptions: {},
},
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 6bffb9ef2..fc243e277 100644
--- a/packages/coding-agent/src/modes/components/status-line/segments.ts
+++ b/packages/coding-agent/src/modes/components/status-line/segments.ts
@@ -5,6 +5,8 @@ import { TERMINAL } from "@oh-my-pi/pi-tui";
import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
import { theme } from "../../../modes/theme/theme";
import { shortenPath } from "../../../tools/render-utils";
+import { getSessionAccentHex } from "../../../utils/session-color";
+import { sanitizeStatusText } from "../../shared";
import { getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds";
import type { RenderedSegment, SegmentContext, StatusLineSegment, StatusLineSegmentId } from "./types";
@@ -354,6 +356,18 @@ const cacheWriteSegment: StatusLineSegment = {
},
};
+const sessionNameSegment: StatusLineSegment = {
+ id: "session_name",
+ render(ctx) {
+ const name = ctx.session.sessionManager?.getSessionName();
+ if (!name) return { content: "", visible: false };
+
+ const hex = getSessionAccentHex(name);
+ const ansi = Bun.color(hex, "ansi-16m") ?? theme.getFgAnsi("accent");
+ return { content: `${ansi}${sanitizeStatusText(name)}\x1b[39m`, visible: true };
+ },
+};
+
// ═══════════════════════════════════════════════════════════════════════════
// Segment Registry
// ═══════════════════════════════════════════════════════════════════════════
@@ -379,6 +393,7 @@ export const SEGMENTS: Record = {
hostname: hostnameSegment,
cache_read: cacheReadSegment,
cache_write: cacheWriteSegment,
+ session_name: sessionNameSegment,
};
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts
index e2033032a..6288f1eb4 100644
--- a/packages/coding-agent/src/modes/controllers/command-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/command-controller.ts
@@ -686,6 +686,23 @@ export class CommandController {
}
}
+ async handleRenameCommand(title: string): Promise {
+ try {
+ const stored = await this.ctx.sessionManager.setSessionName(title, "user");
+ if (!stored) {
+ this.ctx.showError("Session name cannot be empty.");
+ return;
+ }
+ const name = this.ctx.sessionManager.getSessionName()!;
+ setSessionTerminalTitle(name, this.ctx.sessionManager.getCwd());
+ this.ctx.statusLine.invalidate();
+ this.ctx.updateEditorBorderColor();
+ this.ctx.showStatus(`Session renamed to "${name}".`);
+ } catch (err) {
+ this.ctx.showError(`Rename failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
async handleBashCommand(command: string, excludeFromContext = false): Promise {
const isDeferred = this.ctx.session.isStreaming;
this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts
index 1c6e7a57a..3faec3978 100644
--- a/packages/coding-agent/src/modes/controllers/input-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/input-controller.ts
@@ -342,8 +342,14 @@ export class InputController {
generateSessionTitle(text, registry, this.ctx.settings, this.ctx.session.sessionId, this.ctx.session.model)
.then(async title => {
if (title) {
- await this.ctx.sessionManager.setSessionName(title);
- setSessionTerminalTitle(title, this.ctx.sessionManager.getCwd());
+ const applied = await this.ctx.sessionManager.setSessionName(title, "auto");
+ if (applied) {
+ setSessionTerminalTitle(
+ this.ctx.sessionManager.getSessionName()!,
+ this.ctx.sessionManager.getCwd(),
+ );
+ this.ctx.updateEditorBorderColor();
+ }
}
})
.catch(() => {});
diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts
index dbcc95f41..da87260ef 100644
--- a/packages/coding-agent/src/modes/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive-mode.ts
@@ -39,6 +39,7 @@ import { STTController, type SttState } from "../stt";
import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
import type { EventBus } from "../utils/event-bus";
import { getEditorCommand, openInEditor } from "../utils/external-editor";
+import { getSessionAccentHex } from "../utils/session-color";
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
import type { AssistantMessageComponent } from "./components/assistant-message";
import type { BashExecutionComponent } from "./components/bash-execution";
@@ -393,6 +394,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.ui.start();
pushTerminalTitle();
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
+ this.updateEditorBorderColor();
this.#syncEditorMaxHeight();
this.isInitialized = true;
this.ui.requestRender(true);
@@ -531,8 +533,20 @@ export class InteractiveMode implements InteractiveModeContext {
} else if (this.isPythonMode) {
this.editor.borderColor = theme.getPythonModeBorderColor();
} else {
- const level = this.session.thinkingLevel ?? ThinkingLevel.Off;
- this.editor.borderColor = theme.getThinkingBorderColor(level);
+ const sessionName = this.sessionManager.getSessionName();
+ if (sessionName) {
+ const hex = getSessionAccentHex(sessionName);
+ const ansi = Bun.color(hex, "ansi-16m");
+ if (ansi) {
+ this.editor.borderColor = (str: string) => `${ansi}${str}\x1b[0m`;
+ } else {
+ const level = this.session.thinkingLevel ?? ThinkingLevel.Off;
+ this.editor.borderColor = theme.getThinkingBorderColor(level);
+ }
+ } else {
+ const level = this.session.thinkingLevel ?? ThinkingLevel.Off;
+ this.editor.borderColor = theme.getThinkingBorderColor(level);
+ }
}
this.updateEditorTopBorder();
this.ui.requestRender();
@@ -1302,6 +1316,10 @@ export class InteractiveMode implements InteractiveModeContext {
return this.#commandController.handleMoveCommand(targetPath);
}
+ handleRenameCommand(title: string): Promise {
+ return this.#commandController.handleRenameCommand(title);
+ }
+
handleMemoryCommand(text: string): Promise {
return this.#commandController.handleMemoryCommand(text);
}
diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts
index bda4637e5..245d2d42e 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts
@@ -751,7 +751,10 @@ export async function runRpcMode(session: AgentSession): Promise {
if (!name) {
return error(id, "set_session_name", "Session name cannot be empty");
}
- session.setSessionName(name);
+ const applied = await session.setSessionName(name, "user");
+ if (!applied) {
+ return error(id, "set_session_name", "Session name cannot be empty");
+ }
return success(id, "set_session_name");
}
diff --git a/packages/coding-agent/src/modes/shared.ts b/packages/coding-agent/src/modes/shared.ts
index af592178d..e920a8238 100644
--- a/packages/coding-agent/src/modes/shared.ts
+++ b/packages/coding-agent/src/modes/shared.ts
@@ -5,10 +5,10 @@ import { theme } from "./theme/theme";
// Text Sanitization
// ═══════════════════════════════════════════════════════════════════════════
-/** Sanitize text for display in a single-line status. Replaces newlines/tabs with space, collapses runs, trims. */
+/** Sanitize text for display in a single-line status. Strips C0/C1 control characters (including ANSI ESC), collapses whitespace, trims. */
export function sanitizeStatusText(text: string): string {
return text
- .replace(/[\r\n\t]/g, " ")
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
.replace(/ +/g, " ")
.trim();
}
diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts
index 246644dbb..6bdd14a1d 100644
--- a/packages/coding-agent/src/modes/types.ts
+++ b/packages/coding-agent/src/modes/types.ts
@@ -187,6 +187,7 @@ export interface InteractiveModeContext {
handleCompactCommand(customInstructions?: string): Promise;
handleHandoffCommand(customInstructions?: string): Promise;
handleMoveCommand(targetPath: string): Promise;
+ handleRenameCommand(title: string): Promise;
handleMemoryCommand(text: string): Promise;
handleSTTToggle(): Promise;
executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise;
diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts
index 22b2ac473..74a4381b9 100644
--- a/packages/coding-agent/src/session/agent-session.ts
+++ b/packages/coding-agent/src/session/agent-session.ts
@@ -3314,8 +3314,8 @@ export class AgentSession {
/**
* Set a display name for the current session.
*/
- setSessionName(name: string): void {
- this.sessionManager.setSessionName(name);
+ setSessionName(name: string, source: "auto" | "user" = "auto"): Promise {
+ return this.sessionManager.setSessionName(name, source);
}
/**
diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts
index 460e9fc87..9670eaff1 100644
--- a/packages/coding-agent/src/session/session-manager.ts
+++ b/packages/coding-agent/src/session/session-manager.ts
@@ -1378,6 +1378,7 @@ export async function resolveResumableSession(
interface SessionManagerStateSnapshot {
sessionId: string;
sessionName: string | undefined;
+ titleSource: "auto" | "user" | undefined;
sessionFile: string | undefined;
flushed: boolean;
needsFullRewriteOnNextPersist: boolean;
@@ -1387,6 +1388,7 @@ interface SessionManagerStateSnapshot {
export class SessionManager {
#sessionId: string = "";
#sessionName: string | undefined;
+ #titleSource: "auto" | "user" | undefined;
#sessionFile: string | undefined;
#flushed: boolean = false;
#needsFullRewriteOnNextPersist: boolean = false;
@@ -1438,6 +1440,7 @@ export class SessionManager {
return {
sessionId: this.#sessionId,
sessionName: this.#sessionName,
+ titleSource: this.#titleSource,
sessionFile: this.#sessionFile,
flushed: this.#flushed,
needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
@@ -1450,6 +1453,7 @@ export class SessionManager {
restoreState(snapshot: SessionManagerStateSnapshot): void {
this.#sessionId = snapshot.sessionId;
this.#sessionName = snapshot.sessionName;
+ this.#titleSource = snapshot.titleSource;
this.#sessionFile = snapshot.sessionFile;
this.#flushed = snapshot.flushed;
this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
@@ -1489,6 +1493,7 @@ export class SessionManager {
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
this.#sessionId = header?.id ?? Snowflake.next();
this.#sessionName = header?.title;
+ this.#titleSource = undefined; // loaded session: reset so auto-title can run for untitled sessions
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
@@ -1666,6 +1671,7 @@ export class SessionManager {
this.#persistErrorReported = false;
this.#sessionId = Snowflake.next();
this.#sessionName = undefined;
+ this.#titleSource = undefined;
const timestamp = new Date().toISOString();
const header: SessionHeader = {
type: "session",
@@ -1953,17 +1959,42 @@ export class SessionManager {
return manager.getPath(id);
}
+ /** The source that set the session name: "user" (manual /rename or RPC) or "auto" (generated title). */
+ get titleSource(): "auto" | "user" | undefined {
+ return this.#titleSource;
+ }
+
getSessionName(): string | undefined {
return this.#sessionName;
}
- async setSessionName(name: string): Promise {
- this.#sessionName = name;
+ /** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
+ static #sanitizeName(name: string): string {
+ return name
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
+ .replace(/ +/g, " ")
+ .trim();
+ }
+
+ /**
+ * Set the session display name.
+ * @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
+ * Auto-generated titles are silently ignored when the user has already set a name.
+ */
+ async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise {
+ // User-set names take permanent precedence over auto-generated ones.
+ if (this.#titleSource === "user" && source === "auto") return false;
+
+ const sanitized = SessionManager.#sanitizeName(name);
+ if (!sanitized) return false;
+
+ this.#sessionName = sanitized;
+ this.#titleSource = source;
// Update the in-memory header (so first flush includes title)
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
if (header) {
- header.title = name;
+ header.title = sanitized;
}
// Update the session file header with the title (if already flushed)
@@ -1971,6 +2002,7 @@ export class SessionManager {
if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
await this.#rewriteFile();
}
+ return true;
}
_persist(entry: SessionEntry): void {
diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts
index c101e3f83..14b06ecf9 100644
--- a/packages/coding-agent/src/slash-commands/builtin-registry.ts
+++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts
@@ -561,6 +561,23 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [
await runtime.ctx.handleMemoryCommand(command.text);
},
},
+ {
+ name: "rename",
+ description: "Rename the current session",
+ inlineHint: "",
+ allowArgs: true,
+ handle: async (command, runtime) => {
+ const title = command.args.trim();
+ if (!title) {
+ runtime.ctx.showError("Usage: /rename ");
+ runtime.ctx.editor.setText("");
+ return;
+ }
+ runtime.ctx.editor.setText("");
+ await runtime.ctx.handleRenameCommand(title);
+ },
+ },
+
{
name: "move",
description: "Move session to a different working directory",
diff --git a/packages/coding-agent/src/utils/session-color.ts b/packages/coding-agent/src/utils/session-color.ts
new file mode 100644
index 000000000..22593a165
--- /dev/null
+++ b/packages/coding-agent/src/utils/session-color.ts
@@ -0,0 +1,34 @@
+/**
+ * Derive a stable hue (0-359) from a string using djb2 hash.
+ */
+function nameToHue(name: string): number {
+ let hash = 5381;
+ for (let i = 0; i < name.length; i++) {
+ hash = ((hash << 5) + hash) ^ name.charCodeAt(i);
+ hash = hash >>> 0; // keep 32-bit unsigned
+ }
+ return hash % 360;
+}
+
+/**
+ * Convert HSL (h: 0-360, s: 0-1, l: 0-1) to a CSS hex string.
+ */
+function hslToHex(h: number, s: number, l: number): string {
+ const a = s * Math.min(l, 1 - l);
+ const f = (n: number) => {
+ const k = (n + h / 30) % 12;
+ const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
+ return Math.round(255 * color)
+ .toString(16)
+ .padStart(2, "0");
+ };
+ return `#${f(0)}${f(8)}${f(4)}`;
+}
+
+/**
+ * Derive a stable CSS hex accent color from a session name.
+ * High saturation, vivid — suitable for both status bar text and border coloring.
+ */
+export function getSessionAccentHex(name: string): string {
+ return hslToHex(nameToHue(name), 0.9, 0.72);
+}
diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts
index 34a764c27..50783f4f3 100644
--- a/packages/coding-agent/src/utils/title-generator.ts
+++ b/packages/coding-agent/src/utils/title-generator.ts
@@ -159,10 +159,10 @@ export function formatSessionTerminalTitle(sessionName: string | undefined, cwd?
}
/**
- * Set the terminal title using OSC 2. Unsupported terminals ignore it.
+ * Set the terminal title using OSC 0 (sets both tab and window title). Unsupported terminals ignore it.
*/
export function setTerminalTitle(title: string): void {
- process.stdout.write(`\x1b]2;${sanitizeTerminalTitlePart(title) ?? DEFAULT_TERMINAL_TITLE}\x07`);
+ process.stdout.write(`\x1b]0;${sanitizeTerminalTitlePart(title) ?? DEFAULT_TERMINAL_TITLE}\x07`);
}
export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void {