Reviewed PR description, comments, and commits.
UnderstandAnalyzed linked issues and comments.
UnderstandRead relevant source files for context.
UnderstandAdded changelog entries.
EditRan checks on the changes.
VerifyYou are given one or more GitHub PR URLs: https://github.com/badlogic/pi-mono/pull/863
For each PR URL, do the following in order:
packages/*/CHANGELOG.md files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. Verify:
### Breaking Changes, ### Added, ### Fixed, etc.)Fixed foo ([#123](https://github.com/badlogic/pi-mono/pull/123) by [@user](https://github.com/user))### Breaking Changes, not just ### FixedOutput format per PR: PR: Changelog:
If no issues are found, say so under Bad and Ugly.
>gh pr view 863 --repo badlogic/pi-mono --json title,body,comments,commits,files,author,state,labels,headRefName,baseRefName{"author":{"id":"MDQ6VXNlcjMxOTQxMzU5","is_bot":false,"login":"svkozak","name":"Sergii Kozak"},"baseRefName":"main","body":"### Summary\r\n\r\nRecent update introduced `/name` command to add session names and this PR adds renaming support to the interactive `/resume` picker allowing to rename sessions in the list without opening them individually. Also modifies how session 'recency' is derived (if only metadata changed, session's 'modified' date remains the same).\r\n\r\n#### Interactive /resume rename support\r\n\r\n- Ctrl+R opens a full-page rename mode inside the session selector (no overlay).\r\n- Rename mode shows “Rename Session”, an input, and save/cancel hints.\r\n- Esc/Ctrl+C cancels rename; Enter saves.\r\n\r\n\r\n\r\n\r\n \r\n#### Get session 'modified' time based on latest activity\r\n\r\n- Session “modified” time is derived from last user/assistant message timestamp (with fallbacks), not file mtime, so renaming (or /name) doesn’t reorder the recent list. (`mtime` is kept as a fallback)\r\n\r\n#### Refresh/reload behaviour\r\n\r\n- After rename/delete, the session list refreshes so updated names appear immediately.\r\n- Small refactor of refresh logic into a shared `loadScope(...)` helper to reduce duplication.\r\n\r\n ### Tests\r\n\r\n - Added/updated tests to cover:\r\n - rename hint behavior (/resume vs --resume)\r\n - rename flow (Ctrl+R → rename UI → typing → Enter)\r\n - “modified timestamp uses last message timestamp” behavior\r\n - existing delete/path/scope behavior remains covered","comments":[],"commits":[{"authoredDate":"2026-01-20T00:08:48Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjMxOTQxMzU5","login":"svkozak","name":"Sergii Kozak"}],"committedDate":"2026-01-20T00:08:48Z","messageBody":"Session list now displays last message timestamp as modified time\ninstead of file mtime. Ctrl+N enters rename mode in the interactive\nresume picker, allowing quick session renaming without leaving the\nselector. Rename hint is shown only in interactive mode, not in the\nCLI --resume picker./","messageHeadline":"Add session renaming in interactive mode resume picker","oid":"8076bf44df3a0d5e55b672b0e9ac96950d0dc5a1"},{"authoredDate":"2026-01-20T00:09:06Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjMxOTQxMzU5","login":"svkozak","name":"Sergii Kozak"}],"committedDate":"2026-01-20T00:09:06Z","messageBody":"","messageHeadline":"Merge branch 'main' into rename-session","oid":"e98313acfdab374b28aee414b14a3509f6939acf"},{"authoredDate":"2026-01-20T01:19:29Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjMxOTQxMzU5","login":"svkozak","name":"Sergii Kozak"}],"committedDate":"2026-01-20T01:19:29Z","messageBody":"","messageHeadline":"Add docs entry for renaming in picker","oid":"4dd1b5281580d8f8f4be078bb195e146925c9e15"},{"authoredDate":"2026-01-24T04:46:43Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjMxOTQxMzU5","login":"svkozak","name":"Sergii Kozak"}],"committedDate":"2026-01-24T04:46:43Z","messageBody":"","messageHeadline":"Update shortcut to ctrl+r for session renaming","oid":"9410fe5f80cabc744c38f1588dcb5e2319520892"}],"files":[{"path":"packages/coding-agent/README.md","additions":1,"deletions":0},{"path":"packages/coding-agent/src/cli/session-picker.ts","additions":1,"deletions":0},{"path":"packages/coding-agent/src/core/session-manager.ts","additions":41,"deletions":1},{"path":"packages/coding-agent/src/modes/interactive/components/session-selector.ts","additions":211,"deletions":87},{"path":"packages/coding-agent/src/modes/interactive/interactive-mode.ts","additions":11,"deletions":1},{"path":"packages/coding-agent/test/session-info-modified-timestamp.test.ts","additions":83,"deletions":0},{"path":"packages/coding-agent/test/session-selector-rename.test.ts","additions":101,"deletions":0}],"headRefName":"rename-session","labels":[],"state":"OPEN","title":"Rename session from /resume session list"}>gh pr diff 863 --repo badlogic/pi-monodiff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md
index 8633df591..2ddd594d1 100644
--- a/packages/coding-agent/README.md
+++ b/packages/coding-agent/README.md
@@ -546,6 +546,7 @@ pi --session a8ec1c2a # Resume by session ID (partial UUID)
In the `/resume` picker:
- `Ctrl+P` toggles display of the session `.jsonl` file path
- `Ctrl+D` deletes the selected session (inline confirmation; uses `trash` if available and cannot delete the active session)
+- `Ctrl+R` opens `Rename Session` component, `Esc` cancels and returns to session list, `Enter` applies the new name and reloads the list.
**Resuming by session ID:** The `--session` flag accepts a session UUID (or prefix). Session IDs are visible in filenames under `~/.pi/agent/sessions/<project>/` (e.g., `2025-12-13T17-47-46-817Z_a8ec1c2a-5a5f-4699-88cb-03e7d3cb9292.jsonl`). The UUID is the part after the underscore. You can also search by session ID in the `pi -r` picker.
diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts
index 3ca22355c..e2e8d0f15 100644
--- a/packages/coding-agent/src/cli/session-picker.ts
+++ b/packages/coding-agent/src/cli/session-picker.ts
@@ -39,6 +39,7 @@ export async function selectSession(
process.exit(0);
},
() => ui.requestRender(),
+ { showRenameHint: false },
);
ui.addChild(selector);
diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts
index 95e2d9db4..17ff363d1 100644
--- a/packages/coding-agent/src/core/session-manager.ts
+++ b/packages/coding-agent/src/core/session-manager.ts
@@ -498,6 +498,44 @@ function extractTextContent(message: Message): string {
.join(" ");
}
+function getLastActivityTime(entries: FileEntry[]): number | undefined {
+ let lastActivityTime: number | undefined;
+
+ for (const entry of entries) {
+ if (entry.type !== "message") continue;
+
+ const message = (entry as SessionMessageEntry).message;
+ if (!isMessageWithContent(message)) continue;
+ if (message.role !== "user" && message.role !== "assistant") continue;
+
+ const msgTimestamp = (message as { timestamp?: number }).timestamp;
+ if (typeof msgTimestamp === "number") {
+ lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);
+ continue;
+ }
+
+ const entryTimestamp = (entry as SessionEntryBase).timestamp;
+ if (typeof entryTimestamp === "string") {
+ const t = new Date(entryTimestamp).getTime();
+ if (!Number.isNaN(t)) {
+ lastActivityTime = Math.max(lastActivityTime ?? 0, t);
+ }
+ }
+ }
+
+ return lastActivityTime;
+}
+
+function getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {
+ const lastActivityTime = getLastActivityTime(entries);
+ if (typeof lastActivityTime === "number" && lastActivityTime > 0) {
+ return new Date(lastActivityTime);
+ }
+
+ const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
+ return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;
+}
+
async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
try {
const content = await readFile(filePath, "utf8");
@@ -550,13 +588,15 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
const cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : "";
+ const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);
+
return {
path: filePath,
id: (header as SessionHeader).id,
cwd,
name,
created: new Date((header as SessionHeader).timestamp),
- modified: stats.mtime,
+ modified,
messageCount,
firstMessage: firstMessage || "(no messages)",
allMessagesText: allMessages.join(" "),
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 c6d8d13b9..41ee1ba78 100644
--- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts
@@ -10,6 +10,7 @@ import {
Input,
matchesKey,
Spacer,
+ Text,
truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui";
@@ -56,6 +57,7 @@ class SessionSelectorHeader implements Component {
private confirmingDeletePath: string | null = null;
private statusMessage: { type: "info" | "error"; message: string } | null = null;
private statusTimeout: ReturnType<typeof setTimeout> | null = null;
+ private showRenameHint = false;
constructor(scope: SessionScope, sortMode: SortMode, requestRender: () => void) {
this.scope = scope;
@@ -85,6 +87,10 @@ class SessionSelectorHeader implements Component {
this.showPath = showPath;
}
+ setShowRenameHint(show: boolean): void {
+ this.showRenameHint = show;
+ }
+
setConfirmingDeletePath(path: string | null): void {
this.confirmingDeletePath = path;
}
@@ -146,12 +152,15 @@ class SessionSelectorHeader implements Component {
const pathState = this.showPath ? "(on)" : "(off)";
const sep = theme.fg("muted", " · ");
const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're:<pattern> regex · "phrase" exact');
- const hint2 =
- rawKeyHint("ctrl+r", "sort") +
- sep +
- rawKeyHint("ctrl+d", "delete") +
- sep +
- rawKeyHint("ctrl+p", `path ${pathState}`);
+ const hint2Parts = [
+ rawKeyHint("ctrl+n", "sort"),
+ rawKeyHint("ctrl+d", "delete"),
+ rawKeyHint("ctrl+p", `path ${pathState}`),
+ ];
+ if (this.showRenameHint) {
+ hint2Parts.push(rawKeyHint("ctrl+r", "rename"));
+ }
+ const hint2 = hint2Parts.join(sep);
hintLine1 = truncateToWidth(hint1, width, "…");
hintLine2 = truncateToWidth(hint2, width, "…");
}
@@ -164,6 +173,10 @@ class SessionSelectorHeader implements Component {
* Custom session list component with multi-line items and search
*/
class SessionList implements Component, Focusable {
+ public getSelectedSessionPath(): string | undefined {
+ const selected = this.filteredSessions[this.selectedIndex];
+ return selected?.path;
+ }
private allSessions: SessionInfo[] = [];
private filteredSessions: SessionInfo[] = [];
private selectedIndex: number = 0;
@@ -181,6 +194,7 @@ class SessionList implements Component, Focusable {
public onTogglePath?: (showPath: boolean) => void;
public onDeleteConfirmationChange?: (path: string | null) => void;
public onDeleteSession?: (sessionPath: string) => Promise<void>;
+ public onRenameSession?: (sessionPath: string) => void;
public onError?: (message: string) => void;
private maxVisible: number = 5; // Max sessions visible (each session: message + metadata + optional path + blank)
@@ -369,7 +383,7 @@ class SessionList implements Component, Focusable {
return;
}
- if (matchesKey(keyData, "ctrl+r")) {
+ if (matchesKey(keyData, "ctrl+n")) {
this.onToggleSort?.();
return;
}
@@ -387,6 +401,15 @@ class SessionList implements Component, Focusable {
return;
}
+ // Ctrl+R: rename selected session
+ if (matchesKey(keyData, "ctrl+r")) {
+ const selected = this.filteredSessions[this.selectedIndex];
+ if (selected) {
+ this.onRenameSession?.(selected.path);
+ }
+ return;
+ }
+
// Ctrl+Backspace: non-invasive convenience alias for delete
// Only triggers deletion when the query is empty; otherwise it is forwarded to the input
if (matchesKey(keyData, "ctrl+backspace")) {
@@ -483,6 +506,21 @@ async function deleteSessionFile(
* Component that renders a session selector
*/
export class SessionSelectorComponent extends Container implements Focusable {
+ handleInput(data: string): void {
+ if (this.mode === "rename") {
+ const kb = getEditorKeybindings();
+ if (kb.matches(data, "selectCancel") || matchesKey(data, "ctrl+c")) {
+ this.exitRenameMode();
+ return;
+ }
+ this.renameInput.handleInput(data);
+ return;
+ }
+
+ this.sessionList.handleInput(data);
+ }
+
+ private canRename = true;
private sessionList: SessionList;
private header: SessionSelectorHeader;
private scope: SessionScope = "current";
@@ -493,10 +531,15 @@ export class SessionSelectorComponent extends Container implements Focusable {
private allSessionsLoader: SessionsLoader;
private onCancel: () => void;
private requestRender: () => void;
+ private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise<void>;
private currentLoading = false;
private allLoading = false;
private allLoadSeq = 0;
+ private mode: "list" | "rename" = "list";
+ private renameInput = new Input();
+ private renameTargetPath: string | null = null;
+
// Focusable implementation - propagate to sessionList for IME cursor positioning
private _focused = false;
get focused(): boolean {
@@ -505,6 +548,24 @@ export class SessionSelectorComponent extends Container implements Focusable {
set focused(value: boolean) {
this._focused = value;
this.sessionList.focused = value;
+ this.renameInput.focused = value;
+ if (value && this.mode === "rename") {
+ this.renameInput.focused = true;
+ }
+ }
+
+ private buildBaseLayout(content: Component, options?: { showHeader?: boolean }): void {
+ this.clear();
+ this.addChild(new Spacer(1));
+ this.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
+ this.addChild(new Spacer(1));
+ if (options?.showHeader ?? true) {
+ this.addChild(this.header);
+ this.addChild(new Spacer(1));
+ }
+ this.addChild(content);
+ this.addChild(new Spacer(1));
+ this.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
}
constructor(
@@ -514,6 +575,10 @@ export class SessionSelectorComponent extends Container implements Focusable {
onCancel: () => void,
onExit: () => void,
requestRender: () => void,
+ options?: {
+ renameSession?: (sessionPath: string, currentName: string | undefined) => Promise<void>;
+ showRenameHint?: boolean;
+ },
currentSessionFilePath?: string,
) {
super();
@@ -522,17 +587,20 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.onCancel = onCancel;
this.requestRender = requestRender;
this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender);
-
- // Add header
- this.addChild(new Spacer(1));
- this.addChild(new DynamicBorder());
- this.addChild(new Spacer(1));
- this.addChild(this.header);
- this.addChild(new Spacer(1));
+ const renameSession = options?.renameSession;
+ this.renameSession = renameSession;
+ this.canRename = !!renameSession;
+ this.header.setShowRenameHint(options?.showRenameHint ?? this.canRename);
// Create session list (starts empty, will be populated after load)
this.sessionList = new SessionList([], false, this.sortMode, currentSessionFilePath);
+ this.buildBaseLayout(this.sessionList);
+
+ this.renameInput.onSubmit = (value) => {
+ void this.confirmRename(value);
+ };
+
// Ensure header status timeouts are cleared when leaving the selector
const clearStatusMessage = () => this.header.setStatusMessage(null);
this.sessionList.onSelect = (sessionPath) => {
@@ -549,6 +617,15 @@ export class SessionSelectorComponent extends Container implements Focusable {
};
this.sessionList.onToggleScope = () => this.toggleScope();
this.sessionList.onToggleSort = () => this.toggleSortMode();
+ this.sessionList.onRenameSession = (sessionPath) => {
+ if (!renameSession) return;
+ if (this.scope === "current" && this.currentLoading) return;
+ if (this.scope === "all" && this.allLoading) return;
+
+ const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []);
+ const session = sessions.find((s) => s.path === sessionPath);
+ this.enterRenameMode(sessionPath, session?.name);
+ };
// Sync list events to header
this.sessionList.onTogglePath = (showPath) => {
@@ -582,6 +659,7 @@ export class SessionSelectorComponent extends Container implements Focusable {
const msg = result.method === "trash" ? "Session moved to trash" : "Session deleted";
this.header.setStatusMessage({ type: "info", message: msg }, 2000);
+ await this.refreshSessionsAfterMutation();
} else {
const errorMessage = result.error ?? "Unknown error";
this.header.setStatusMessage({ type: "error", message: `Failed to delete: ${errorMessage}` }, 3000);
@@ -590,48 +668,128 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.requestRender();
};
- this.addChild(this.sessionList);
-
- // Add bottom border
- this.addChild(new Spacer(1));
- this.addChild(new DynamicBorder());
-
// Start loading current sessions immediately
this.loadCurrentSessions();
}
private loadCurrentSessions(): void {
- this.currentLoading = true;
- this.header.setScope("current");
+ void this.loadScope("current", "initial");
+ }
+
+ private enterRenameMode(sessionPath: string, currentName: string | undefined): void {
+ this.mode = "rename";
+ this.renameTargetPath = sessionPath;
+ this.renameInput.setValue(currentName ?? "");
+ this.renameInput.focused = true;
+
+ const panel = new Container();
+ panel.addChild(new Text(theme.bold("Rename Session"), 1, 0));
+ panel.addChild(new Spacer(1));
+ panel.addChild(this.renameInput);
+ panel.addChild(new Spacer(1));
+ panel.addChild(new Text(theme.fg("muted", "Enter to save · Esc/Ctrl+C to cancel"), 1, 0));
+
+ this.buildBaseLayout(panel, { showHeader: false });
+ this.requestRender();
+ }
+
+ private exitRenameMode(): void {
+ this.mode = "list";
+ this.renameTargetPath = null;
+
+ this.buildBaseLayout(this.sessionList);
+
+ this.requestRender();
+ }
+
+ private async confirmRename(value: string): Promise<void> {
+ const next = value.trim();
+ if (!next) return;
+ const target = this.renameTargetPath;
+ if (!target) {
+ this.exitRenameMode();
+ return;
+ }
+
+ // Find current name for callback
+ const renameSession = this.renameSession;
+ if (!renameSession) {
+ this.exitRenameMode();
+ return;
+ }
+
+ try {
+ await renameSession(target, next);
+ await this.refreshSessionsAfterMutation();
+ } finally {
+ this.exitRenameMode();
+ }
+ }
+
+ private async loadScope(scope: SessionScope, reason: "initial" | "refresh" | "toggle"): Promise<void> {
+ const showCwd = scope === "all";
+
+ // Mark loading
+ if (scope === "current") {
+ this.currentLoading = true;
+ } else {
+ this.allLoading = true;
+ }
+
+ const seq = scope === "all" ? ++this.allLoadSeq : undefined;
+ this.header.setScope(scope);
this.header.setLoading(true);
this.requestRender();
- this.currentSessionsLoader((loaded, total) => {
- if (this.scope !== "current") return;
+ const onProgress = (loaded: number, total: number) => {
+ if (scope !== this.scope) return;
+ if (seq !== undefined && seq !== this.allLoadSeq) return;
this.header.setProgress(loaded, total);
this.requestRender();
- })
- .then((sessions) => {
+ };
+
+ try {
+ const sessions = await (scope === "current"
+ ? this.currentSessionsLoader(onProgress)
+ : this.allSessionsLoader(onProgress));
+
+ if (scope === "current") {
this.currentSessions = sessions;
this.currentLoading = false;
+ } else {
+ this.allSessions = sessions;
+ this.allLoading = false;
+ }
- if (this.scope !== "current") return;
+ if (scope !== this.scope) return;
+ if (seq !== undefined && seq !== this.allLoadSeq) return;
- this.header.setLoading(false);
- this.sessionList.setSessions(sessions, false);
- this.requestRender();
- })
- .catch((error: unknown) => {
+ this.header.setLoading(false);
+ this.sessionList.setSessions(sessions, showCwd);
+ this.requestRender();
+
+ if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
+ this.onCancel();
+ }
+ } catch (err) {
+ if (scope === "current") {
this.currentLoading = false;
- const message = error instanceof Error ? error.message : String(error);
+ } else {
+ this.allLoading = false;
+ }
- if (this.scope !== "current") return;
+ if (scope !== this.scope) return;
+ if (seq !== undefined && seq !== this.allLoadSeq) return;
- this.header.setLoading(false);
- this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
- this.sessionList.setSessions([], false);
- this.requestRender();
- });
+ const message = err instanceof Error ? err.message : String(err);
+ this.header.setLoading(false);
+ this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
+
+ if (reason === "initial") {
+ this.sessionList.setSessions([], showCwd);
+ }
+ this.requestRender();
+ }
}
private toggleSortMode(): void {
@@ -641,6 +799,10 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.requestRender();
}
+ private async refreshSessionsAfterMutation(): Promise<void> {
+ await this.loadScope(this.scope, "refresh");
+ }
+
private toggleScope(): void {
if (this.scope === "current") {
this.scope = "all";
@@ -653,55 +815,17 @@ export class SessionSelectorComponent extends Container implements Focusable {
return;
}
- this.header.setLoading(true);
- this.sessionList.setSessions([], true);
- this.requestRender();
-
- if (this.allLoading) return;
-
- this.allLoading = true;
- const seq = ++this.allLoadSeq;
-
- this.allSessionsLoader((loaded, total) => {
- if (seq !== this.allLoadSeq) return;
- if (this.scope !== "all") return;
- this.header.setProgress(loaded, total);
- this.requestRender();
- })
- .then((sessions) => {
- this.allSessions = sessions;
- this.allLoading = false;
-
- if (seq !== this.allLoadSeq) return;
- if (this.scope !== "all") return;
-
- this.header.setLoading(false);
- this.sessionList.setSessions(sessions, true);
- this.requestRender();
-
- if (sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
- this.onCancel();
- }
- })
- .catch((error: unknown) => {
- this.allLoading = false;
- const message = error instanceof Error ? error.message : String(error);
-
- if (seq !== this.allLoadSeq) return;
- if (this.scope !== "all") return;
-
- this.header.setLoading(false);
- this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
- this.sessionList.setSessions([], true);
- this.requestRender();
- });
- } else {
- this.scope = "current";
- this.header.setScope(this.scope);
- this.header.setLoading(this.currentLoading);
- this.sessionList.setSessions(this.currentSessions ?? [], false);
- this.requestRender();
+ if (!this.allLoading) {
+ void this.loadScope("all", "toggle");
+ }
+ return;
}
+
+ this.scope = "current";
+ this.header.setScope(this.scope);
+ this.header.setLoading(this.currentLoading);
+ this.sessionList.setSessions(this.currentSessions ?? [], false);
+ this.requestRender();
}
getSessionList(): SessionList {
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index b71defdbc..53df332d1 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -3088,9 +3088,19 @@ export class InteractiveMode {
void this.shutdown();
},
() => this.ui.requestRender(),
+ {
+ renameSession: async (sessionFilePath: string, nextName: string | undefined) => {
+ const next = (nextName ?? "").trim();
+ if (!next) return;
+ const mgr = SessionManager.open(sessionFilePath);
+ mgr.appendSessionInfo(next);
+ },
+ showRenameHint: true,
+ },
+
this.sessionManager.getSessionFile(),
);
- return { component: selector, focus: selector.getSessionList() };
+ return { component: selector, focus: selector };
});
}
diff --git a/packages/coding-agent/test/session-info-modified-timestamp.test.ts b/packages/coding-agent/test/session-info-modified-timestamp.test.ts
new file mode 100644
index 000000000..7089cdb2b
--- /dev/null
+++ b/packages/coding-agent/test/session-info-modified-timestamp.test.ts
@@ -0,0 +1,83 @@
+import { writeFileSync } from "node:fs";
+import { stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import type { SessionHeader } from "../src/core/session-manager.js";
+import { SessionManager } from "../src/core/session-manager.js";
+import { initTheme } from "../src/modes/interactive/theme/theme.js";
+
+function createSessionFile(path: string): void {
+ const header: SessionHeader = {
+ type: "session",
+ id: "test-session",
+ version: 3,
+ timestamp: new Date(0).toISOString(),
+ cwd: "/tmp",
+ };
+ writeFileSync(path, `${JSON.stringify(header)}\n`, "utf8");
+
+ // SessionManager only persists once it has seen at least one assistant message.
+ // Add a minimal assistant entry so subsequent appends are persisted.
+ const mgr = SessionManager.open(path);
+ mgr.appendMessage({
+ role: "assistant",
+ content: [{ type: "text", text: "hi" }],
+ api: "openai-completions",
+ provider: "openai",
+ model: "test",
+ usage: {
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: "stop",
+ timestamp: Date.now(),
+ });
+}
+
+describe("SessionInfo.modified", () => {
+ beforeAll(() => initTheme("dark"));
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("uses last user/assistant message timestamp instead of file mtime", async () => {
+ const filePath = join(tmpdir(), `pi-session-${Date.now()}-modified.jsonl`);
+ createSessionFile(filePath);
+
+ const before = await stat(filePath);
+ // Ensure the file mtime can differ from our message timestamp even on coarse filesystems.
+ await new Promise((r) => setTimeout(r, 10));
+
+ const mgr = SessionManager.open(filePath);
+ const msgTime = Date.now();
+ mgr.appendMessage({
+ role: "assistant",
+ content: [{ type: "text", text: "later" }],
+ api: "openai-completions",
+ provider: "openai",
+ model: "test",
+ usage: {
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: "stop",
+ timestamp: msgTime,
+ });
+
+ const sessions = await SessionManager.list("/tmp", filePath.replace(/\/[^/]+$/, ""));
+ const s = sessions.find((x) => x.path === filePath);
+ expect(s).toBeDefined();
+ expect(s!.modified.getTime()).toBe(msgTime);
+ expect(s!.modified.getTime()).not.toBe(before.mtime.getTime());
+ });
+});
diff --git a/packages/coding-agent/test/session-selector-rename.test.ts b/packages/coding-agent/test/session-selector-rename.test.ts
new file mode 100644
index 000000000..801814d5a
--- /dev/null
+++ b/packages/coding-agent/test/session-selector-rename.test.ts
@@ -0,0 +1,101 @@
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import type { SessionInfo } from "../src/core/session-manager.js";
+import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js";
+import { initTheme } from "../src/modes/interactive/theme/theme.js";
+
+async function flushPromises(): Promise<void> {
+ await new Promise<void>((resolve) => {
+ setImmediate(resolve);
+ });
+}
+
+function makeSession(overrides: Partial<SessionInfo> & { id: string }): SessionInfo {
+ return {
+ path: overrides.path ?? `/tmp/${overrides.id}.jsonl`,
+ id: overrides.id,
+ cwd: overrides.cwd ?? "",
+ name: overrides.name,
+ created: overrides.created ?? new Date(0),
+ modified: overrides.modified ?? new Date(0),
+ messageCount: overrides.messageCount ?? 1,
+ firstMessage: overrides.firstMessage ?? "hello",
+ allMessagesText: overrides.allMessagesText ?? "hello",
+ };
+}
+
+// Kitty keyboard protocol encoding for Ctrl+R
+const CTRL_R = "\x1b[114;5u";
+
+describe("session selector rename", () => {
+ beforeAll(() => {
+ initTheme("dark");
+ });
+
+ it("shows rename hint in interactive /resume picker configuration", async () => {
+ const sessions = [makeSession({ id: "a" })];
+ const selector = new SessionSelectorComponent(
+ async () => sessions,
+ async () => [],
+ () => {},
+ () => {},
+ () => {},
+ () => {},
+ { showRenameHint: true },
+ );
+ await flushPromises();
+
+ const output = selector.render(120).join("\n");
+ expect(output).toContain("ctrl+r");
+ expect(output).toContain("rename");
+ });
+
+ it("does not show rename hint in --resume picker configuration", async () => {
+ const sessions = [makeSession({ id: "a" })];
+ const selector = new SessionSelectorComponent(
+ async () => sessions,
+ async () => [],
+ () => {},
+ () => {},
+ () => {},
+ () => {},
+ { showRenameHint: false },
+ );
+ await flushPromises();
+
+ const output = selector.render(120).join("\n");
+ expect(output).not.toContain("ctrl+r");
+ expect(output).not.toContain("rename");
+ });
+
+ it("enters rename mode on Ctrl+R and submits with Enter", async () => {
+ const sessions = [makeSession({ id: "a", name: "Old" })];
+ const renameSession = vi.fn(async () => {});
+
+ const selector = new SessionSelectorComponent(
+ async () => sessions,
+ async () => [],
+ () => {},
+ () => {},
+ () => {},
+ () => {},
+ { renameSession, showRenameHint: true },
+ );
+ await flushPromises();
+
+ selector.getSessionList().handleInput(CTRL_R);
+ await flushPromises();
+
+ // Rename mode layout
+ const output = selector.render(120).join("\n");
+ expect(output).toContain("Rename Session");
+ expect(output).not.toContain("Resume Session");
+
+ // Type and submit
+ selector.handleInput("X");
+ selector.handleInput("\r");
+ await flushPromises();
+
+ expect(renameSession).toHaveBeenCalledTimes(1);
+ expect(renameSession).toHaveBeenCalledWith(sessions[0]!.path, "XOld");
+ });
+});continue
Now let me read the relevant source files to validate the changes against the current codebase:
Now let me check the interactive-mode.ts and session-picker.ts files to understand the full context:
Let me read more of the interactive-mode.ts to see the resume command handling:
>rg -n "showResumeSelector" packages/coding-agent/src/modes/interactive/interactive-mode.ts | head -20(no output)>rg -n "showSessionSelector\|handleResumeCommand\|/resume" packages/coding-agent/src/modes/interactive/interactive-mode.ts | head -30(no output)>rg -n "SessionSelectorComponent\|resume" packages/coding-agent/src/modes/interactive/interactive-mode.ts | head -30(no output)>rg -n "session-selector" packages/coding-agent/src/modes/interactive/interactive-mode.ts | head -3093:import { SessionSelectorComponent } from "./components/session-selector.js";>rg -n "SessionSelectorComponent" packages/coding-agent/src/modes/interactive/interactive-mode.ts93:import { SessionSelectorComponent } from "./components/session-selector.js";
3447: const selector = new SessionSelectorComponent(Now I have a full picture. Let me check if there are any issues mentioned in the PR:
>gh issue view 863 --repo badlogic/pi-mono --json title,body,comments,labels,state 2>/dev/null || echo "Not an issue"{"body":"### Summary\r\n\r\nRecent update introduced `/name` command to add session names and this PR adds renaming support to the interactive `/resume` picker allowing to rename sessions in the list without opening them individually. Also modifies how session 'recency' is derived (if only metadata changed, session's 'modified' date remains the same).\r\n\r\n#### Interactive /resume rename support\r\n\r\n- Ctrl+R opens a full-page rename mode inside the session selector (no overlay).\r\n- Rename mode shows “Rename Session”, an input, and save/cancel hints.\r\n- Esc/Ctrl+C cancels rename; Enter saves.\r\n\r\n\r\n\r\n\r\n \r\n#### Get session 'modified' time based on latest activity\r\n\r\n- Session “modified” time is derived from last user/assistant message timestamp (with fallbacks), not file mtime, so renaming (or /name) doesn’t reorder the recent list. (`mtime` is kept as a fallback)\r\n\r\n#### Refresh/reload behaviour\r\n\r\n- After rename/delete, the session list refreshes so updated names appear immediately.\r\n- Small refactor of refresh logic into a shared `loadScope(...)` helper to reduce duplication.\r\n\r\n ### Tests\r\n\r\n - Added/updated tests to cover:\r\n - rename hint behavior (/resume vs --resume)\r\n - rename flow (Ctrl+R → rename UI → typing → Enter)\r\n - “modified timestamp uses last message timestamp” behavior\r\n - existing delete/path/scope behavior remains covered","comments":[],"labels":[],"state":"OPEN","title":"Rename session from /resume session list"}This is a PR, not an issue. Let me verify the tests run correctly and check for any related issues that might be linked:
>cd packages/coding-agent && npm test -- test/session-info-modified-timestamp.test.ts test/session-selector-rename.test.ts 2>&1 | tail -50> @mariozechner/pi-coding-agent@0.49.3 test
> vitest --run test/session-info-modified-timestamp.test.ts test/session-selector-rename.test.ts
RUN v3.2.4 /Users/badlogic/workspaces/pi-mono/packages/coding-agent
No test files found, exiting with code 1
filter: test/session-info-modified-timestamp.test.ts, test/session-selector-rename.test.ts
include: **/*.{test,spec}.?(c|m)[jt]s?(x)
exclude: **/node_modules/**, **/dist/**, **/cypress/**, **/.{idea,git,cache,output,temp}/**, **/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*
npm error Lifecycle script `test` failed with error:
npm error code 1
npm error path /Users/badlogic/workspaces/pi-mono/packages/coding-agent
npm error workspace @mariozechner/pi-coding-agent@0.49.3
npm error location /Users/badlogic/workspaces/pi-mono/packages/coding-agent
npm error command failed
npm error command sh -c vitest --run test/session-info-modified-timestamp.test.ts test/session-selector-rename.test.tsThe test files don't exist on main branch yet (they're part of the PR). Let me verify the diff more thoroughly to identify any issues:
>cd packages/coding-agent && npm run check 2>&1 | head -100npm error Lifecycle script `check` failed with error:
npm error workspace @mariozechner/pi-coding-agent@0.49.3
npm error location /Users/badlogic/workspaces/pi-mono/packages/coding-agent
npm error Missing script: "check"
npm error
npm error To see a list of scripts, run:
npm error npm run --workspace=@mariozechner/pi-coding-agent@0.49.3>cd packages/coding-agent && npm run lint 2>&1 | head -100npm error Lifecycle script `lint` failed with error:
npm error workspace @mariozechner/pi-coding-agent@0.49.3
npm error location /Users/badlogic/workspaces/pi-mono/packages/coding-agent
npm error Missing script: "lint"
npm error
npm error Did you mean this?
npm error npm link # Symlink a package folder
npm error
npm error To see a list of scripts, run:
npm error npm run --workspace=@mariozechner/pi-coding-agent@0.49.3>npm run check 2>&1 | head -200> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && cd packages/web-ui && npm run check
Checked 432 files in 278ms. No fixes applied.
> @mariozechner/pi-web-ui@0.49.3 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 42ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.Now let me analyze the changes in more detail. Let me look at the hint line changes:
>rg -n "ctrl\+r" packages/coding-agent/src/modes/interactive/components/session-selector.ts150: rawKeyHint("ctrl+r", "sort") +
372: if (matchesKey(keyData, "ctrl+r")) {I see a discrepancy! Looking at the PR diff, the shortcut for "sort" is being changed from ctrl+r to ctrl+n, and ctrl+r is being repurposed for "rename". But the diff shows:
// Line in the diff for hint2Parts:rawKeyHint("ctrl+n", "sort"),But the current main branch still has:
rawKeyHint("ctrl+r", "sort")Let me also check if there's a keybinding conflict in the SessionList:
>rg -n "ctrl\+n\|ctrl\+r" packages/coding-agent/src/modes/interactive/components/session-selector.ts(no output)
Command exited with code 1>rg -n "ctrl.r\|ctrl.n" packages/coding-agent/src/modes/interactive/components/session-selector.ts(no output)
Command exited with code 1>grep -n "ctrl" packages/coding-agent/src/modes/interactive/components/session-selector.ts150: rawKeyHint("ctrl+r", "sort") +
152: rawKeyHint("ctrl+d", "delete") +
154: rawKeyHint("ctrl+p", `path ${pathState}`);
357: if (kb.matches(keyData, "selectCancel") || matchesKey(keyData, "ctrl+c")) {
372: if (matchesKey(keyData, "ctrl+r")) {
378: if (matchesKey(keyData, "ctrl+p")) {
385: if (matchesKey(keyData, "ctrl+d")) {
392: if (matchesKey(keyData, "ctrl+backspace")) {So in the current main branch:
ctrl+r is used for "sort"ctrl+n bindingIn the PR diff:
ctrl+n will be used for "sort"ctrl+r will be used for "rename"This is a keybinding change that may impact users who are used to ctrl+r for sorting. Let me also check the README doc:
>rg -n "ctrl.r\|ctrl.p\|Ctrl.D" packages/coding-agent/README.md | head -20(no output)>grep -n "Ctrl" packages/coding-agent/README.md | head -30163:**Windows Terminal:** Add to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file):
306:| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
347:| Ctrl+A / Home / Cmd+Left | Start of line |
348:| Ctrl+E / End / Cmd+Right | End of line |
356:| Shift+Enter | New line (Ctrl+Enter on Windows Terminal) |
357:| Ctrl+W / Alt+Backspace | Delete word backwards |
359:| Ctrl+U | Delete to start of line |
360:| Ctrl+K | Delete to end of line |
361:| Ctrl+Y | Paste most recently deleted text |
363:| Ctrl+- | Undo |
371:| Ctrl+C | Clear editor (first) / exit (second) |
372:| Ctrl+D | Exit (when editor is empty) |
373:| Ctrl+Z | Suspend to background (use `fg` in shell to resume) |
375:| Ctrl+P / Shift+Ctrl+P | Cycle models forward/backward (scoped by `--models`) |
376:| Ctrl+L | Open model selector |
377:| Ctrl+O | Toggle tool output expansion |
378:| Ctrl+T | Toggle thinking block visibility |
379:| Ctrl+G | Edit message in external editor (`$VISUAL` or `$EDITOR`) |
380:| Ctrl+V | Paste image from clipboard |
509:**Pasting images:** Press `Ctrl+V` to paste an image from your clipboard.
555:- `Ctrl+P` toggles display of the session `.jsonl` file path
556:- `Ctrl+D` deletes the selected session (inline confirmation; uses `trash` if available and cannot delete the active session)
593:- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
836:| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
1314:| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling. Supports glob patterns (e.g., `anthropic/*`, `*sonnet*:high`) and fuzzy matching (e.g., `sonnet,haiku:low`) |
1390:| `VISUAL`, `EDITOR` | External editor for Ctrl+G (e.g., `vim`, `code --wait`) |Looking at line 555-556, the README documents Ctrl+P and Ctrl+D but not Ctrl+R for sort. The PR adds documentation for Ctrl+R rename at that location.
Now let me check if there are any docs that need updating:
>ls packages/coding-agent/docs/compaction.md
custom-provider.md
extensions.md
rpc.md
sdk.md
session.md
skills.md
theme.md
tree.md
tui.md>rg -l "resume\|session.*selector\|session.*picker" packages/coding-agent/docs/(no output)
Command exited with code 1>rg "resume" packages/coding-agent/docs/*.mdpackages/coding-agent/docs/session.md:Pi also supports deleting sessions interactively from `/resume` (select a session and press `Ctrl+D`, then confirm). When available, pi uses the `trash` CLI to avoid permanent deletion.
packages/coding-agent/docs/session.md:The session name is displayed in the session selector (`/resume`) instead of the first message when set.
packages/coding-agent/docs/extensions.md:/new (new session) or /resume (switch session)
packages/coding-agent/docs/extensions.md:Fired when starting a new session (`/new`) or switching sessions (`/resume`).
packages/coding-agent/docs/extensions.md: // event.reason - "new" or "resume"
packages/coding-agent/docs/extensions.md: // event.targetSessionFile - session we're switching to (only for "resume")
packages/coding-agent/docs/extensions.md: // event.reason - "new" or "resume"The session.md mentions the /resume delete feature but not the sort toggle. The README at line 555-556 documents Ctrl+P and Ctrl+D. The PR adds Ctrl+R for rename.
Now let me provide my review:
PR: https://github.com/badlogic/pi-mono/pull/863
Changelog:
packages/coding-agent/CHANGELOG.md under ## [Unreleased] → ### Added for the rename feature, and could include a note about the "modified timestamp" fix as well.Good:
/resume picker without opening them first, which is a UX improvementCtrl+R was previously used for "sort", now moved to Ctrl+N to free Ctrl+R for "rename"SessionSelectorComponent itself (not just getSessionList()) to allow rename input handlingBad:
pkg:coding-agent label: Should be added to the PRCtrl+R for rename but doesn't mention that "sort" moved from Ctrl+R to Ctrl+N. Users familiar with Ctrl+R for sorting will be surprised. The hint line in the UI is updated, but changelog should note this keybinding change.packages/coding-agent/docs/session.md mentions /resume delete via Ctrl+D but could mention rename via Ctrl+RUgly:
Questions or Assumptions:
Ctrl+N keybinding intentional for sort? Ctrl+N is often associated with "new" in other applications. Was an alternative considered (e.g., Ctrl+S for sort)?showRenameHint option defaults to this.canRename when not explicitly provided in options. In the --resume CLI picker (session-picker.ts), it's explicitly set to false. This is correct behavior, but worth confirming the intent is that rename is only available in interactive /resume, not CLI --resume.Change summary:
/resume picker: Press Ctrl+R to enter rename mode, type new name, press Enter to save or Esc/Ctrl+C to cancelCtrl+R to Ctrl+N to free up Ctrl+R for renameloadScope() helper: Consolidated loading logic reduces code duplicationTests:
session-info-modified-timestamp.test.ts: Tests that SessionInfo.modified uses last message timestamp instead of file mtimesession-selector-rename.test.ts: Tests rename hint visibility (shown in /resume, hidden in --resume), and rename flow (Ctrl+R → UI → typing → Enter)merge via gh cli, pull from origin, change to CTRL+S for sort if its not already used
Let me check if Ctrl+S is already used in the session selector:
>grep -n "ctrl+s\|ctrl.s" packages/coding-agent/src/modes/interactive/components/session-selector.ts(no output)
Command exited with code 1>rg -i "ctrl.s" packages/coding-agent/src/modes/interactive/components/session-selector.ts(no output)
Command exited with code 1Ctrl+S is not used in the session selector. Let me merge the PR, pull, and make the change:
>gh pr merge 863 --repo badlogic/pi-mono --squash --delete-branch(no output)>git pull origin mainFrom https://github.com/badlogic/pi-mono
* branch main -> FETCH_HEAD
676de103..b5873507 main -> origin/main
Updating 676de103..b5873507
Fast-forward
packages/coding-agent/README.md | 1 +
packages/coding-agent/src/cli/session-picker.ts | 1 +
packages/coding-agent/src/core/session-manager.ts | 42 ++-
.../interactive/components/session-selector.ts | 298 +++++++++++++++------
.../src/modes/interactive/interactive-mode.ts | 12 +-
.../test/session-info-modified-timestamp.test.ts | 83 ++++++
.../test/session-selector-rename.test.ts | 101 +++++++
7 files changed, 449 insertions(+), 89 deletions(-)
create mode 100644 packages/coding-agent/test/session-info-modified-timestamp.test.ts
create mode 100644 packages/coding-agent/test/session-selector-rename.test.tsNow let me read the current session-selector.ts and update ctrl+n to ctrl+s for sort:
>grep -n "ctrl+n" packages/coding-agent/src/modes/interactive/components/session-selector.ts156: rawKeyHint("ctrl+n", "sort"),
386: if (matchesKey(keyData, "ctrl+n")) {Now let me add the changelog entry:
Now let me run the check:
>npm run check 2>&1> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && cd packages/web-ui && npm run check
Checked 434 files in 271ms. No fixes applied.
> @mariozechner/pi-web-ui@0.49.3 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 41ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.Now let me commit and push:
>git add packages/coding-agent/src/modes/interactive/components/session-selector.ts packages/coding-agent/CHANGELOG.md(no output)>git commit -m "fix(coding-agent): change sort keybinding from Ctrl+N to Ctrl+S in /resume picker
Also add changelog entries for #863 session rename feature."Running formatting, linting, and type checking...
> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && cd packages/web-ui && npm run check
Checked 434 files in 280ms. No fixes applied.
> @mariozechner/pi-web-ui@0.49.3 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 37ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.
✅ All pre-commit checks passed!
[main 4bb21b7f] fix(coding-agent): change sort keybinding from Ctrl+N to Ctrl+S in /resume picker
2 files changed, 5 insertions(+), 2 deletions(-)>git pushTo https://github.com/badlogic/pi-mono.git
b5873507..4bb21b7f main -> mainDone. I merged PR #863 via squash merge, pulled the changes, and updated the sort keybinding from Ctrl+N to Ctrl+S. Also added changelog entries:
/resume picker via Ctrl+RCtrl+R to Ctrl+S// Line in the diff for hint2Parts:rawKeyHint("ctrl+n", "sort"),rawKeyHint("ctrl+r", "sort")