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<StatusLineSegmentId, { label: string; short: string } hostname: { label: "Host", short: "hostname" }, cache_read: { label: "Cache ↓", short: "cache read" }, cache_write: { label: "Cache ↑", short: "cache write" }, + session_name: { label: "Session Name", short: "named session" }, }; type Column = "left" | "right" | "disabled"; diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line.ts index e2c1895cc..8327c1179 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line.ts @@ -9,6 +9,7 @@ import { theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; import { calculatePromptTokens } from "../../session/compaction/compaction"; import * as git from "../../utils/git"; +import { getSessionAccentHex } from "../../utils/session-color"; import { sanitizeStatusText } from "../shared"; import { canReuseCachedPr, @@ -471,7 +472,11 @@ export class StatusLineComponent implements Component { leftWidth = groupWidth(left, leftCapWidth, leftSepWidth); rightWidth = groupWidth(right, rightCapWidth, rightSepWidth); const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth); - const gapFill = theme.fg("border", theme.boxRound.horizontal.repeat(gapWidth)); + const sessionName = this.session.sessionManager?.getSessionName(); + const gapColor = sessionName + ? (Bun.color(getSessionAccentHex(sessionName), "ansi-16m") ?? theme.getFgAnsi("border")) + : theme.getFgAnsi("border"); + const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[0m`; return leftGroup + gapFill + rightGroup; } diff --git a/packages/coding-agent/src/modes/components/status-line/presets.ts b/packages/coding-agent/src/modes/components/status-line/presets.ts index 62deb5e6c..961c78824 100644 --- a/packages/coding-agent/src/modes/components/status-line/presets.ts +++ b/packages/coding-agent/src/modes/components/status-line/presets.ts @@ -3,7 +3,7 @@ import type { PresetDef, StatusLinePreset } from "./types"; export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = { 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<StatusLinePreset, PresetDef> = { 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<StatusLinePreset, PresetDef> = { 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<StatusLinePreset, PresetDef> = { 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<StatusLinePreset, PresetDef> = { // 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<StatusLinePreset, PresetDef> = { 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<StatusLinePreset, PresetDef> = { 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<StatusLineSegmentId, StatusLineSegment> = { 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<void> { + 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<void> { 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<void> { + return this.#commandController.handleRenameCommand(title); + } + handleMemoryCommand(text: string): Promise<void> { 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<never> { 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<void>; handleHandoffCommand(customInstructions?: string): Promise<void>; handleMoveCommand(targetPath: string): Promise<void>; + handleRenameCommand(title: string): Promise<void>; handleMemoryCommand(text: string): Promise<void>; handleSTTToggle(): Promise<void>; executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void>; 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<boolean> { + 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<void> { - 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<boolean> { + // 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<BuiltinSlashCommandSpec> = [ await runtime.ctx.handleMemoryCommand(command.text); }, }, + { + name: "rename", + description: "Rename the current session", + inlineHint: "<title>", + allowArgs: true, + handle: async (command, runtime) => { + const title = command.args.trim(); + if (!title) { + runtime.ctx.showError("Usage: /rename <title>"); + 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 {