From 8f95a13e073ec57f0f51ddf7e3906470f5365f95 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 12 Jan 2026 16:56:39 +0100 Subject: [PATCH] feat(coding-agent): add session naming via /name command and extension API - Add SessionInfoEntry type for session metadata - Add /name command to set session display name - Add pi.setSessionName() and pi.getSessionName() extension API - Session selector shows name (in warning color) instead of first message when set - Session name included in fuzzy search - /session command displays name when set closes #650 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 1 + packages/coding-agent/docs/extensions.md | 19 ++++++++ packages/coding-agent/docs/session.md | 12 +++++ .../src/core/extensions/loader.ts | 10 ++++ .../src/core/extensions/runner.ts | 2 + .../coding-agent/src/core/extensions/types.ts | 16 +++++++ .../coding-agent/src/core/session-manager.ts | 48 ++++++++++++++++++- packages/coding-agent/src/index.ts | 1 + .../components/session-selector.ts | 18 +++++-- .../src/modes/interactive/interactive-mode.ts | 36 ++++++++++++++ packages/coding-agent/src/modes/print-mode.ts | 6 +++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 6 +++ .../test/compaction-extensions.test.ts | 2 + 14 files changed, 173 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eb124244..ae209f3f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Session naming: `/name ` command sets a display name shown in the session selector instead of the first message. Useful for distinguishing forked sessions. Extensions can use `pi.setSessionName()` and `pi.getSessionName()`. ([#650](https://github.com/badlogic/pi-mono/pull/650) by [@scutifer](https://github.com/scutifer)) - Extension example: `notify.ts` for desktop notifications via OSC 777 escape sequence ([#658](https://github.com/badlogic/pi-mono/pull/658) by [@ferologics](https://github.com/ferologics)) - Inline hint for queued messages showing the `Alt+Up` restore shortcut ([#657](https://github.com/badlogic/pi-mono/pull/657) by [@tmustier](https://github.com/tmustier)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 98f7e24d..eeeebb8a 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -240,6 +240,7 @@ The agent reads, writes, and edits files, and executes commands via bash. | `/export [file]` | Export session to self-contained HTML | | `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) | | `/session` | Show session info: path, message counts, token usage, cost | +| `/name ` | Set session display name (shown in session selector) | | `/hotkeys` | Show all keyboard shortcuts | | `/changelog` | Display full version history | | `/tree` | Navigate session tree in-place (search, filter, label entries) | diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 809fed6c..7bad92cc 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -797,6 +797,25 @@ pi.on("session_start", async (_event, ctx) => { **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [snake.ts](../examples/extensions/snake.ts), [tools.ts](../examples/extensions/tools.ts) +### pi.setSessionName(name) + +Set the session display name (shown in session selector instead of first message). + +```typescript +pi.setSessionName("Refactor auth module"); +``` + +### pi.getSessionName() + +Get the current session name, if set. + +```typescript +const name = pi.getSessionName(); +if (name) { + console.log(`Session: ${name}`); +} +``` + ### pi.registerCommand(name, options) Register a command. diff --git a/packages/coding-agent/docs/session.md b/packages/coding-agent/docs/session.md index 06aa220c..281fec48 100644 --- a/packages/coding-agent/docs/session.md +++ b/packages/coding-agent/docs/session.md @@ -138,6 +138,16 @@ User-defined bookmark/marker on an entry. Set `label` to `undefined` to clear a label. +### SessionInfoEntry + +Session metadata (e.g., user-defined display name). Set via `/name` command or `pi.setSessionName()` in extensions. + +```json +{"type":"session_info","id":"k1l2m3n4","parentId":"j0k1l2m3","timestamp":"2024-12-03T14:35:00.000Z","name":"Refactor auth module"} +``` + +The session name is displayed in the session selector (`/resume`) instead of the first message when set. + ## Tree Structure Entries form a tree: @@ -222,6 +232,7 @@ Key methods for working with sessions programmatically: - `appendModelChange(provider, modelId)` - Record model change - `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction - `appendCustomEntry(customType, data?)` - Extension state (not in context) +- `appendSessionInfo(name)` - Set session display name - `appendCustomMessageEntry(customType, content, display, details?)` - Extension message (in context) - `appendLabelChange(targetId, label)` - Set/clear label @@ -241,3 +252,4 @@ Key methods for working with sessions programmatically: - `buildSessionContext()` - Get messages for LLM - `getEntries()` - All entries (excluding header) - `getHeader()` - Session metadata +- `getSessionName()` - Get display name from latest session_info entry diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 917cb9ee..1e91f553 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -86,6 +86,8 @@ export function createExtensionRuntime(): ExtensionRuntime { sendMessage: notInitialized, sendUserMessage: notInitialized, appendEntry: notInitialized, + setSessionName: notInitialized, + getSessionName: notInitialized, getActiveTools: notInitialized, getAllTools: notInitialized, setActiveTools: notInitialized, @@ -169,6 +171,14 @@ function createExtensionAPI( runtime.appendEntry(customType, data); }, + setSessionName(name: string): void { + runtime.setSessionName(name); + }, + + getSessionName(): string | undefined { + return runtime.getSessionName(); + }, + exec(command: string, args: string[], options?: ExecOptions) { return execCommand(command, args, options?.cwd ?? cwd, options); }, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 73ba87dd..5194c8f6 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -140,6 +140,8 @@ export class ExtensionRunner { this.runtime.sendMessage = actions.sendMessage; this.runtime.sendUserMessage = actions.sendUserMessage; this.runtime.appendEntry = actions.appendEntry; + this.runtime.setSessionName = actions.setSessionName; + this.runtime.getSessionName = actions.getSessionName; this.runtime.getActiveTools = actions.getActiveTools; this.runtime.getAllTools = actions.getAllTools; this.runtime.setActiveTools = actions.setActiveTools; diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 76cb8f90..6ae880fd 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -727,6 +727,16 @@ export interface ExtensionAPI { /** Append a custom entry to the session for state persistence (not sent to LLM). */ appendEntry(customType: string, data?: T): void; + // ========================================================================= + // Session Metadata + // ========================================================================= + + /** Set the session display name (shown in session selector). */ + setSessionName(name: string): void; + + /** Get the current session name, if set. */ + getSessionName(): string | undefined; + /** Execute a shell command. */ exec(command: string, args: string[], options?: ExecOptions): Promise; @@ -797,6 +807,10 @@ export type SendUserMessageHandler = ( export type AppendEntryHandler = (customType: string, data?: T) => void; +export type SetSessionNameHandler = (name: string) => void; + +export type GetSessionNameHandler = () => string | undefined; + export type GetActiveToolsHandler = () => string[]; export type GetAllToolsHandler = () => string[]; @@ -825,6 +839,8 @@ export interface ExtensionActions { sendMessage: SendMessageHandler; sendUserMessage: SendUserMessageHandler; appendEntry: AppendEntryHandler; + setSessionName: SetSessionNameHandler; + getSessionName: GetSessionNameHandler; getActiveTools: GetActiveToolsHandler; getAllTools: GetAllToolsHandler; setActiveTools: SetActiveToolsHandler; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index a35ff8c9..93ab733a 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -106,6 +106,12 @@ export interface LabelEntry extends SessionEntryBase { label: string | undefined; } +/** Session metadata entry (e.g., user-defined display name). */ +export interface SessionInfoEntry extends SessionEntryBase { + type: "session_info"; + name?: string; +} + /** * Custom message entry for extensions to inject messages into LLM context. * Use customType to identify your extension's entries. @@ -135,7 +141,8 @@ export type SessionEntry = | BranchSummaryEntry | CustomEntry | CustomMessageEntry - | LabelEntry; + | LabelEntry + | SessionInfoEntry; /** Raw file entry (includes header) */ export type FileEntry = SessionHeader | SessionEntry; @@ -159,6 +166,8 @@ export interface SessionInfo { id: string; /** Working directory where the session was started. Empty string for old sessions. */ cwd: string; + /** User-defined display name from session_info entries. */ + name?: string; created: Date; modified: Date; messageCount: number; @@ -180,6 +189,7 @@ export type ReadonlySessionManager = Pick< | "getHeader" | "getEntries" | "getTree" + | "getSessionName" >; /** Generate a unique short ID (8 hex chars, collision-checked) */ @@ -511,8 +521,17 @@ async function buildSessionInfo(filePath: string): Promise { let messageCount = 0; let firstMessage = ""; const allMessages: string[] = []; + let name: string | undefined; for (const entry of entries) { + // Extract session name (use latest) + if (entry.type === "session_info") { + const infoEntry = entry as SessionInfoEntry; + if (infoEntry.name) { + name = infoEntry.name.trim(); + } + } + if (entry.type !== "message") continue; messageCount++; @@ -535,6 +554,7 @@ async function buildSessionInfo(filePath: string): Promise { path: filePath, id: (header as SessionHeader).id, cwd, + name, created: new Date((header as SessionHeader).timestamp), modified: stats.mtime, messageCount, @@ -815,6 +835,32 @@ export class SessionManager { return entry.id; } + /** Append a session info entry (e.g., display name). Returns entry id. */ + appendSessionInfo(name: string): string { + const entry: SessionInfoEntry = { + type: "session_info", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + name: name.trim(), + }; + this._appendEntry(entry); + return entry.id; + } + + /** Get the current session name from the latest session_info entry, if any. */ + getSessionName(): string | undefined { + // Walk entries in reverse to find the latest session_info with a name + const entries = this.getEntries(); + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "session_info" && entry.name) { + return entry.name; + } + } + return undefined; + } + /** * Append a custom message entry (for extensions) that participates in LLM context. * @param customType Extension identifier for filtering on reload diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index ae34b088..92de873d 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -155,6 +155,7 @@ export { type SessionEntryBase, type SessionHeader, type SessionInfo, + type SessionInfoEntry, SessionManager, type SessionMessageEntry, type ThinkingLevelChangeEntry, diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index eb89acc2..87de017e 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -130,7 +130,7 @@ class SessionList implements Component { this.filteredSessions = fuzzyFilter( this.allSessions, query, - (session) => `${session.id} ${session.allMessagesText} ${session.cwd}`, + (session) => `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`, ); this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); } @@ -167,14 +167,24 @@ class SessionList implements Component { const session = this.filteredSessions[i]; const isSelected = i === this.selectedIndex; - // Normalize first message to single line - const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim(); + // Use session name if set, otherwise first message + const hasName = !!session.name; + const displayText = session.name ?? session.firstMessage; + const normalizedMessage = displayText.replace(/\n/g, " ").trim(); // First line: cursor + message (truncate to visible width) + // Use warning color for custom names to distinguish from first message const cursor = isSelected ? theme.fg("accent", "› ") : " "; const maxMsgWidth = width - 2; // Account for cursor (2 visible chars) const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "..."); - const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); + let styledMsg = truncatedMsg; + if (hasName) { + styledMsg = theme.fg("warning", truncatedMsg); + } + if (isSelected) { + styledMsg = theme.bold(styledMsg); + } + const messageLine = cursor + styledMsg; // Second line: metadata (dimmed) - also truncate for safety const modified = formatSessionDate(session.modified); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 03bdeb9a..3c470377 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -286,6 +286,7 @@ export class InteractiveMode { { name: "export", description: "Export session to HTML file" }, { name: "share", description: "Share session as a secret GitHub gist" }, { name: "copy", description: "Copy last agent message to clipboard" }, + { name: "name", description: "Set session display name" }, { name: "session", description: "Show session info and stats" }, { name: "changelog", description: "Show changelog entries" }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, @@ -677,6 +678,12 @@ export class InteractiveMode { appendEntry: (customType, data) => { this.sessionManager.appendCustomEntry(customType, data); }, + setSessionName: (name) => { + this.sessionManager.appendSessionInfo(name); + }, + getSessionName: () => { + return this.sessionManager.getSessionName(); + }, getActiveTools: () => this.session.getActiveToolNames(), getAllTools: () => this.session.getAllToolNames(), setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames), @@ -1442,6 +1449,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/name" || text.startsWith("/name ")) { + this.handleNameCommand(text); + this.editor.setText(""); + return; + } if (text === "/session") { this.handleSessionCommand(); this.editor.setText(""); @@ -3258,10 +3270,34 @@ export class InteractiveMode { } } + private handleNameCommand(text: string): void { + const name = text.replace(/^\/name\s*/, "").trim(); + if (!name) { + const currentName = this.sessionManager.getSessionName(); + if (currentName) { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0)); + } else { + this.showWarning("Usage: /name "); + } + this.ui.requestRender(); + return; + } + + this.sessionManager.appendSessionInfo(name); + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0)); + this.ui.requestRender(); + } + private handleSessionCommand(): void { const stats = this.session.getSessionStats(); + const sessionName = this.sessionManager.getSessionName(); let info = `${theme.bold("Session Info")}\n\n`; + if (sessionName) { + info += `${theme.fg("dim", "Name:")} ${sessionName}\n`; + } info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; info += `${theme.bold("Messages")}\n`; diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index f93a2f54..460c5ebb 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -48,6 +48,12 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti appendEntry: (customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }, + setSessionName: (name) => { + session.sessionManager.appendSessionInfo(name); + }, + getSessionName: () => { + return session.sessionManager.getSessionName(); + }, getActiveTools: () => session.getActiveToolNames(), getAllTools: () => session.getAllToolNames(), setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index a0337df7..b4487bbf 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -267,6 +267,12 @@ export async function runRpcMode(session: AgentSession): Promise { appendEntry: (customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }, + setSessionName: (name) => { + session.sessionManager.appendSessionInfo(name); + }, + getSessionName: () => { + return session.sessionManager.getSessionName(); + }, getActiveTools: () => session.getActiveToolNames(), getAllTools: () => session.getAllToolNames(), setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index f997937e..957985b5 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -108,6 +108,8 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { sendMessage: async () => {}, sendUserMessage: async () => {}, appendEntry: async () => {}, + setSessionName: () => {}, + getSessionName: () => undefined, getActiveTools: () => [], getAllTools: () => [], setActiveTools: () => {},