feat(coding-agent): add session naming via /name command and extension API

- Add SessionInfoEntry type for session metadata
- Add /name <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
This commit is contained in:
Mario Zechner 2026-01-12 16:56:39 +01:00
parent 7a41975e9e
commit 8f95a13e07
14 changed files with 173 additions and 5 deletions

View file

@ -4,6 +4,7 @@
### Added ### Added
- Session naming: `/name <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)) - 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)) - 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))

View file

@ -240,6 +240,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
| `/export [file]` | Export session to self-contained HTML | | `/export [file]` | Export session to self-contained HTML |
| `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) | | `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) |
| `/session` | Show session info: path, message counts, token usage, cost | | `/session` | Show session info: path, message counts, token usage, cost |
| `/name <name>` | Set session display name (shown in session selector) |
| `/hotkeys` | Show all keyboard shortcuts | | `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display full version history | | `/changelog` | Display full version history |
| `/tree` | Navigate session tree in-place (search, filter, label entries) | | `/tree` | Navigate session tree in-place (search, filter, label entries) |

View file

@ -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) **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) ### pi.registerCommand(name, options)
Register a command. Register a command.

View file

@ -138,6 +138,16 @@ User-defined bookmark/marker on an entry.
Set `label` to `undefined` to clear a label. 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 ## Tree Structure
Entries form a tree: Entries form a tree:
@ -222,6 +232,7 @@ Key methods for working with sessions programmatically:
- `appendModelChange(provider, modelId)` - Record model change - `appendModelChange(provider, modelId)` - Record model change
- `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction - `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction
- `appendCustomEntry(customType, data?)` - Extension state (not in context) - `appendCustomEntry(customType, data?)` - Extension state (not in context)
- `appendSessionInfo(name)` - Set session display name
- `appendCustomMessageEntry(customType, content, display, details?)` - Extension message (in context) - `appendCustomMessageEntry(customType, content, display, details?)` - Extension message (in context)
- `appendLabelChange(targetId, label)` - Set/clear label - `appendLabelChange(targetId, label)` - Set/clear label
@ -241,3 +252,4 @@ Key methods for working with sessions programmatically:
- `buildSessionContext()` - Get messages for LLM - `buildSessionContext()` - Get messages for LLM
- `getEntries()` - All entries (excluding header) - `getEntries()` - All entries (excluding header)
- `getHeader()` - Session metadata - `getHeader()` - Session metadata
- `getSessionName()` - Get display name from latest session_info entry

View file

@ -86,6 +86,8 @@ export function createExtensionRuntime(): ExtensionRuntime {
sendMessage: notInitialized, sendMessage: notInitialized,
sendUserMessage: notInitialized, sendUserMessage: notInitialized,
appendEntry: notInitialized, appendEntry: notInitialized,
setSessionName: notInitialized,
getSessionName: notInitialized,
getActiveTools: notInitialized, getActiveTools: notInitialized,
getAllTools: notInitialized, getAllTools: notInitialized,
setActiveTools: notInitialized, setActiveTools: notInitialized,
@ -169,6 +171,14 @@ function createExtensionAPI(
runtime.appendEntry(customType, data); runtime.appendEntry(customType, data);
}, },
setSessionName(name: string): void {
runtime.setSessionName(name);
},
getSessionName(): string | undefined {
return runtime.getSessionName();
},
exec(command: string, args: string[], options?: ExecOptions) { exec(command: string, args: string[], options?: ExecOptions) {
return execCommand(command, args, options?.cwd ?? cwd, options); return execCommand(command, args, options?.cwd ?? cwd, options);
}, },

View file

@ -140,6 +140,8 @@ export class ExtensionRunner {
this.runtime.sendMessage = actions.sendMessage; this.runtime.sendMessage = actions.sendMessage;
this.runtime.sendUserMessage = actions.sendUserMessage; this.runtime.sendUserMessage = actions.sendUserMessage;
this.runtime.appendEntry = actions.appendEntry; this.runtime.appendEntry = actions.appendEntry;
this.runtime.setSessionName = actions.setSessionName;
this.runtime.getSessionName = actions.getSessionName;
this.runtime.getActiveTools = actions.getActiveTools; this.runtime.getActiveTools = actions.getActiveTools;
this.runtime.getAllTools = actions.getAllTools; this.runtime.getAllTools = actions.getAllTools;
this.runtime.setActiveTools = actions.setActiveTools; this.runtime.setActiveTools = actions.setActiveTools;

View file

@ -727,6 +727,16 @@ export interface ExtensionAPI {
/** Append a custom entry to the session for state persistence (not sent to LLM). */ /** Append a custom entry to the session for state persistence (not sent to LLM). */
appendEntry<T = unknown>(customType: string, data?: T): void; appendEntry<T = unknown>(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. */ /** Execute a shell command. */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>; exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
@ -797,6 +807,10 @@ export type SendUserMessageHandler = (
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void; export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
export type SetSessionNameHandler = (name: string) => void;
export type GetSessionNameHandler = () => string | undefined;
export type GetActiveToolsHandler = () => string[]; export type GetActiveToolsHandler = () => string[];
export type GetAllToolsHandler = () => string[]; export type GetAllToolsHandler = () => string[];
@ -825,6 +839,8 @@ export interface ExtensionActions {
sendMessage: SendMessageHandler; sendMessage: SendMessageHandler;
sendUserMessage: SendUserMessageHandler; sendUserMessage: SendUserMessageHandler;
appendEntry: AppendEntryHandler; appendEntry: AppendEntryHandler;
setSessionName: SetSessionNameHandler;
getSessionName: GetSessionNameHandler;
getActiveTools: GetActiveToolsHandler; getActiveTools: GetActiveToolsHandler;
getAllTools: GetAllToolsHandler; getAllTools: GetAllToolsHandler;
setActiveTools: SetActiveToolsHandler; setActiveTools: SetActiveToolsHandler;

View file

@ -106,6 +106,12 @@ export interface LabelEntry extends SessionEntryBase {
label: string | undefined; 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. * Custom message entry for extensions to inject messages into LLM context.
* Use customType to identify your extension's entries. * Use customType to identify your extension's entries.
@ -135,7 +141,8 @@ export type SessionEntry =
| BranchSummaryEntry | BranchSummaryEntry
| CustomEntry | CustomEntry
| CustomMessageEntry | CustomMessageEntry
| LabelEntry; | LabelEntry
| SessionInfoEntry;
/** Raw file entry (includes header) */ /** Raw file entry (includes header) */
export type FileEntry = SessionHeader | SessionEntry; export type FileEntry = SessionHeader | SessionEntry;
@ -159,6 +166,8 @@ export interface SessionInfo {
id: string; id: string;
/** Working directory where the session was started. Empty string for old sessions. */ /** Working directory where the session was started. Empty string for old sessions. */
cwd: string; cwd: string;
/** User-defined display name from session_info entries. */
name?: string;
created: Date; created: Date;
modified: Date; modified: Date;
messageCount: number; messageCount: number;
@ -180,6 +189,7 @@ export type ReadonlySessionManager = Pick<
| "getHeader" | "getHeader"
| "getEntries" | "getEntries"
| "getTree" | "getTree"
| "getSessionName"
>; >;
/** Generate a unique short ID (8 hex chars, collision-checked) */ /** Generate a unique short ID (8 hex chars, collision-checked) */
@ -511,8 +521,17 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
let messageCount = 0; let messageCount = 0;
let firstMessage = ""; let firstMessage = "";
const allMessages: string[] = []; const allMessages: string[] = [];
let name: string | undefined;
for (const entry of entries) { 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; if (entry.type !== "message") continue;
messageCount++; messageCount++;
@ -535,6 +554,7 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
path: filePath, path: filePath,
id: (header as SessionHeader).id, id: (header as SessionHeader).id,
cwd, cwd,
name,
created: new Date((header as SessionHeader).timestamp), created: new Date((header as SessionHeader).timestamp),
modified: stats.mtime, modified: stats.mtime,
messageCount, messageCount,
@ -815,6 +835,32 @@ export class SessionManager {
return entry.id; 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. * Append a custom message entry (for extensions) that participates in LLM context.
* @param customType Extension identifier for filtering on reload * @param customType Extension identifier for filtering on reload

View file

@ -155,6 +155,7 @@ export {
type SessionEntryBase, type SessionEntryBase,
type SessionHeader, type SessionHeader,
type SessionInfo, type SessionInfo,
type SessionInfoEntry,
SessionManager, SessionManager,
type SessionMessageEntry, type SessionMessageEntry,
type ThinkingLevelChangeEntry, type ThinkingLevelChangeEntry,

View file

@ -130,7 +130,7 @@ class SessionList implements Component {
this.filteredSessions = fuzzyFilter( this.filteredSessions = fuzzyFilter(
this.allSessions, this.allSessions,
query, 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)); 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 session = this.filteredSessions[i];
const isSelected = i === this.selectedIndex; const isSelected = i === this.selectedIndex;
// Normalize first message to single line // Use session name if set, otherwise first message
const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim(); 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) // 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 cursor = isSelected ? theme.fg("accent", " ") : " ";
const maxMsgWidth = width - 2; // Account for cursor (2 visible chars) const maxMsgWidth = width - 2; // Account for cursor (2 visible chars)
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "..."); 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 // Second line: metadata (dimmed) - also truncate for safety
const modified = formatSessionDate(session.modified); const modified = formatSessionDate(session.modified);

View file

@ -286,6 +286,7 @@ export class InteractiveMode {
{ name: "export", description: "Export session to HTML file" }, { name: "export", description: "Export session to HTML file" },
{ name: "share", description: "Share session as a secret GitHub gist" }, { name: "share", description: "Share session as a secret GitHub gist" },
{ name: "copy", description: "Copy last agent message to clipboard" }, { 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: "session", description: "Show session info and stats" },
{ name: "changelog", description: "Show changelog entries" }, { name: "changelog", description: "Show changelog entries" },
{ name: "hotkeys", description: "Show all keyboard shortcuts" }, { name: "hotkeys", description: "Show all keyboard shortcuts" },
@ -677,6 +678,12 @@ export class InteractiveMode {
appendEntry: (customType, data) => { appendEntry: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data); this.sessionManager.appendCustomEntry(customType, data);
}, },
setSessionName: (name) => {
this.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return this.sessionManager.getSessionName();
},
getActiveTools: () => this.session.getActiveToolNames(), getActiveTools: () => this.session.getActiveToolNames(),
getAllTools: () => this.session.getAllToolNames(), getAllTools: () => this.session.getAllToolNames(),
setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames), setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
@ -1442,6 +1449,11 @@ export class InteractiveMode {
this.editor.setText(""); this.editor.setText("");
return; return;
} }
if (text === "/name" || text.startsWith("/name ")) {
this.handleNameCommand(text);
this.editor.setText("");
return;
}
if (text === "/session") { if (text === "/session") {
this.handleSessionCommand(); this.handleSessionCommand();
this.editor.setText(""); 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 <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 { private handleSessionCommand(): void {
const stats = this.session.getSessionStats(); const stats = this.session.getSessionStats();
const sessionName = this.sessionManager.getSessionName();
let info = `${theme.bold("Session Info")}\n\n`; 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", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
info += `${theme.bold("Messages")}\n`; info += `${theme.bold("Messages")}\n`;

View file

@ -48,6 +48,12 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
appendEntry: (customType, data) => { appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data); session.sessionManager.appendCustomEntry(customType, data);
}, },
setSessionName: (name) => {
session.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return session.sessionManager.getSessionName();
},
getActiveTools: () => session.getActiveToolNames(), getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(), getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),

View file

@ -267,6 +267,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
appendEntry: (customType, data) => { appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data); session.sessionManager.appendCustomEntry(customType, data);
}, },
setSessionName: (name) => {
session.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return session.sessionManager.getSessionName();
},
getActiveTools: () => session.getActiveToolNames(), getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(), getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),

View file

@ -108,6 +108,8 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
sendMessage: async () => {}, sendMessage: async () => {},
sendUserMessage: async () => {}, sendUserMessage: async () => {},
appendEntry: async () => {}, appendEntry: async () => {},
setSessionName: () => {},
getSessionName: () => undefined,
getActiveTools: () => [], getActiveTools: () => [],
getAllTools: () => [], getAllTools: () => [],
setActiveTools: () => {}, setActiveTools: () => {},