Rename session from /resume session list (#863)

* Add session renaming in interactive mode resume picker

Session list now displays last message timestamp as modified time
instead of file mtime. Ctrl+N enters rename mode in the interactive
resume picker, allowing quick session renaming without leaving the
selector. Rename hint is shown only in interactive mode, not in the
CLI --resume picker./

* Add docs entry for renaming in picker

* Update shortcut to ctrl+r for session renaming
This commit is contained in:
Sergii Kozak 2026-01-25 10:42:34 -08:00 committed by GitHub
parent 676de103e1
commit b5873507c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 449 additions and 89 deletions

View file

@ -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());
});
});

View file

@ -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");
});
});