mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
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:
parent
676de103e1
commit
b5873507c1
7 changed files with 449 additions and 89 deletions
|
|
@ -554,6 +554,7 @@ pi --session a8ec1c2a # Resume by session ID (partial UUID)
|
||||||
In the `/resume` picker:
|
In the `/resume` picker:
|
||||||
- `Ctrl+P` toggles display of the session `.jsonl` file path
|
- `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+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.
|
**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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export async function selectSession(
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
},
|
},
|
||||||
() => ui.requestRender(),
|
() => ui.requestRender(),
|
||||||
|
{ showRenameHint: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
ui.addChild(selector);
|
ui.addChild(selector);
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,44 @@ function extractTextContent(message: Message): string {
|
||||||
.join(" ");
|
.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> {
|
async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
|
||||||
try {
|
try {
|
||||||
const content = await readFile(filePath, "utf8");
|
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 cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : "";
|
||||||
|
|
||||||
|
const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
id: (header as SessionHeader).id,
|
id: (header as SessionHeader).id,
|
||||||
cwd,
|
cwd,
|
||||||
name,
|
name,
|
||||||
created: new Date((header as SessionHeader).timestamp),
|
created: new Date((header as SessionHeader).timestamp),
|
||||||
modified: stats.mtime,
|
modified,
|
||||||
messageCount,
|
messageCount,
|
||||||
firstMessage: firstMessage || "(no messages)",
|
firstMessage: firstMessage || "(no messages)",
|
||||||
allMessagesText: allMessages.join(" "),
|
allMessagesText: allMessages.join(" "),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Input,
|
Input,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
Spacer,
|
Spacer,
|
||||||
|
Text,
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
visibleWidth,
|
visibleWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
|
|
@ -56,6 +57,7 @@ class SessionSelectorHeader implements Component {
|
||||||
private confirmingDeletePath: string | null = null;
|
private confirmingDeletePath: string | null = null;
|
||||||
private statusMessage: { type: "info" | "error"; message: string } | null = null;
|
private statusMessage: { type: "info" | "error"; message: string } | null = null;
|
||||||
private statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
private statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private showRenameHint = false;
|
||||||
|
|
||||||
constructor(scope: SessionScope, sortMode: SortMode, requestRender: () => void) {
|
constructor(scope: SessionScope, sortMode: SortMode, requestRender: () => void) {
|
||||||
this.scope = scope;
|
this.scope = scope;
|
||||||
|
|
@ -85,6 +87,10 @@ class SessionSelectorHeader implements Component {
|
||||||
this.showPath = showPath;
|
this.showPath = showPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShowRenameHint(show: boolean): void {
|
||||||
|
this.showRenameHint = show;
|
||||||
|
}
|
||||||
|
|
||||||
setConfirmingDeletePath(path: string | null): void {
|
setConfirmingDeletePath(path: string | null): void {
|
||||||
this.confirmingDeletePath = path;
|
this.confirmingDeletePath = path;
|
||||||
}
|
}
|
||||||
|
|
@ -146,12 +152,15 @@ class SessionSelectorHeader implements Component {
|
||||||
const pathState = this.showPath ? "(on)" : "(off)";
|
const pathState = this.showPath ? "(on)" : "(off)";
|
||||||
const sep = theme.fg("muted", " · ");
|
const sep = theme.fg("muted", " · ");
|
||||||
const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're:<pattern> regex · "phrase" exact');
|
const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're:<pattern> regex · "phrase" exact');
|
||||||
const hint2 =
|
const hint2Parts = [
|
||||||
rawKeyHint("ctrl+r", "sort") +
|
rawKeyHint("ctrl+n", "sort"),
|
||||||
sep +
|
rawKeyHint("ctrl+d", "delete"),
|
||||||
rawKeyHint("ctrl+d", "delete") +
|
rawKeyHint("ctrl+p", `path ${pathState}`),
|
||||||
sep +
|
];
|
||||||
rawKeyHint("ctrl+p", `path ${pathState}`);
|
if (this.showRenameHint) {
|
||||||
|
hint2Parts.push(rawKeyHint("ctrl+r", "rename"));
|
||||||
|
}
|
||||||
|
const hint2 = hint2Parts.join(sep);
|
||||||
hintLine1 = truncateToWidth(hint1, width, "…");
|
hintLine1 = truncateToWidth(hint1, width, "…");
|
||||||
hintLine2 = truncateToWidth(hint2, width, "…");
|
hintLine2 = truncateToWidth(hint2, width, "…");
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +173,10 @@ class SessionSelectorHeader implements Component {
|
||||||
* Custom session list component with multi-line items and search
|
* Custom session list component with multi-line items and search
|
||||||
*/
|
*/
|
||||||
class SessionList implements Component, Focusable {
|
class SessionList implements Component, Focusable {
|
||||||
|
public getSelectedSessionPath(): string | undefined {
|
||||||
|
const selected = this.filteredSessions[this.selectedIndex];
|
||||||
|
return selected?.path;
|
||||||
|
}
|
||||||
private allSessions: SessionInfo[] = [];
|
private allSessions: SessionInfo[] = [];
|
||||||
private filteredSessions: SessionInfo[] = [];
|
private filteredSessions: SessionInfo[] = [];
|
||||||
private selectedIndex: number = 0;
|
private selectedIndex: number = 0;
|
||||||
|
|
@ -181,6 +194,7 @@ class SessionList implements Component, Focusable {
|
||||||
public onTogglePath?: (showPath: boolean) => void;
|
public onTogglePath?: (showPath: boolean) => void;
|
||||||
public onDeleteConfirmationChange?: (path: string | null) => void;
|
public onDeleteConfirmationChange?: (path: string | null) => void;
|
||||||
public onDeleteSession?: (sessionPath: string) => Promise<void>;
|
public onDeleteSession?: (sessionPath: string) => Promise<void>;
|
||||||
|
public onRenameSession?: (sessionPath: string) => void;
|
||||||
public onError?: (message: string) => void;
|
public onError?: (message: string) => void;
|
||||||
private maxVisible: number = 5; // Max sessions visible (each session: message + metadata + optional path + blank)
|
private maxVisible: number = 5; // Max sessions visible (each session: message + metadata + optional path + blank)
|
||||||
|
|
||||||
|
|
@ -369,7 +383,7 @@ class SessionList implements Component, Focusable {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesKey(keyData, "ctrl+r")) {
|
if (matchesKey(keyData, "ctrl+n")) {
|
||||||
this.onToggleSort?.();
|
this.onToggleSort?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -387,6 +401,15 @@ class SessionList implements Component, Focusable {
|
||||||
return;
|
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
|
// Ctrl+Backspace: non-invasive convenience alias for delete
|
||||||
// Only triggers deletion when the query is empty; otherwise it is forwarded to the input
|
// Only triggers deletion when the query is empty; otherwise it is forwarded to the input
|
||||||
if (matchesKey(keyData, "ctrl+backspace")) {
|
if (matchesKey(keyData, "ctrl+backspace")) {
|
||||||
|
|
@ -483,6 +506,21 @@ async function deleteSessionFile(
|
||||||
* Component that renders a session selector
|
* Component that renders a session selector
|
||||||
*/
|
*/
|
||||||
export class SessionSelectorComponent extends Container implements Focusable {
|
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 sessionList: SessionList;
|
||||||
private header: SessionSelectorHeader;
|
private header: SessionSelectorHeader;
|
||||||
private scope: SessionScope = "current";
|
private scope: SessionScope = "current";
|
||||||
|
|
@ -493,10 +531,15 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||||
private allSessionsLoader: SessionsLoader;
|
private allSessionsLoader: SessionsLoader;
|
||||||
private onCancel: () => void;
|
private onCancel: () => void;
|
||||||
private requestRender: () => void;
|
private requestRender: () => void;
|
||||||
|
private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise<void>;
|
||||||
private currentLoading = false;
|
private currentLoading = false;
|
||||||
private allLoading = false;
|
private allLoading = false;
|
||||||
private allLoadSeq = 0;
|
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
|
// Focusable implementation - propagate to sessionList for IME cursor positioning
|
||||||
private _focused = false;
|
private _focused = false;
|
||||||
get focused(): boolean {
|
get focused(): boolean {
|
||||||
|
|
@ -505,6 +548,24 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||||
set focused(value: boolean) {
|
set focused(value: boolean) {
|
||||||
this._focused = value;
|
this._focused = value;
|
||||||
this.sessionList.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(
|
constructor(
|
||||||
|
|
@ -514,6 +575,10 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||||
onCancel: () => void,
|
onCancel: () => void,
|
||||||
onExit: () => void,
|
onExit: () => void,
|
||||||
requestRender: () => void,
|
requestRender: () => void,
|
||||||
|
options?: {
|
||||||
|
renameSession?: (sessionPath: string, currentName: string | undefined) => Promise<void>;
|
||||||
|
showRenameHint?: boolean;
|
||||||
|
},
|
||||||
currentSessionFilePath?: string,
|
currentSessionFilePath?: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
@ -522,17 +587,20 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||||
this.onCancel = onCancel;
|
this.onCancel = onCancel;
|
||||||
this.requestRender = requestRender;
|
this.requestRender = requestRender;
|
||||||
this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender);
|
this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender);
|
||||||
|
const renameSession = options?.renameSession;
|
||||||
// Add header
|
this.renameSession = renameSession;
|
||||||
this.addChild(new Spacer(1));
|
this.canRename = !!renameSession;
|
||||||
this.addChild(new DynamicBorder());
|
this.header.setShowRenameHint(options?.showRenameHint ?? this.canRename);
|
||||||
this.addChild(new Spacer(1));
|
|
||||||
this.addChild(this.header);
|
|
||||||
this.addChild(new Spacer(1));
|
|
||||||
|
|
||||||
// Create session list (starts empty, will be populated after load)
|
// Create session list (starts empty, will be populated after load)
|
||||||
this.sessionList = new SessionList([], false, this.sortMode, currentSessionFilePath);
|
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
|
// Ensure header status timeouts are cleared when leaving the selector
|
||||||
const clearStatusMessage = () => this.header.setStatusMessage(null);
|
const clearStatusMessage = () => this.header.setStatusMessage(null);
|
||||||
this.sessionList.onSelect = (sessionPath) => {
|
this.sessionList.onSelect = (sessionPath) => {
|
||||||
|
|
@ -549,6 +617,15 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||||
};
|
};
|
||||||
this.sessionList.onToggleScope = () => this.toggleScope();
|
this.sessionList.onToggleScope = () => this.toggleScope();
|
||||||
this.sessionList.onToggleSort = () => this.toggleSortMode();
|
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
|
// Sync list events to header
|
||||||
this.sessionList.onTogglePath = (showPath) => {
|
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";
|
const msg = result.method === "trash" ? "Session moved to trash" : "Session deleted";
|
||||||
this.header.setStatusMessage({ type: "info", message: msg }, 2000);
|
this.header.setStatusMessage({ type: "info", message: msg }, 2000);
|
||||||
|
await this.refreshSessionsAfterMutation();
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = result.error ?? "Unknown error";
|
const errorMessage = result.error ?? "Unknown error";
|
||||||
this.header.setStatusMessage({ type: "error", message: `Failed to delete: ${errorMessage}` }, 3000);
|
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.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.addChild(this.sessionList);
|
|
||||||
|
|
||||||
// Add bottom border
|
|
||||||
this.addChild(new Spacer(1));
|
|
||||||
this.addChild(new DynamicBorder());
|
|
||||||
|
|
||||||
// Start loading current sessions immediately
|
// Start loading current sessions immediately
|
||||||
this.loadCurrentSessions();
|
this.loadCurrentSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadCurrentSessions(): void {
|
private loadCurrentSessions(): void {
|
||||||
this.currentLoading = true;
|
void this.loadScope("current", "initial");
|
||||||
this.header.setScope("current");
|
}
|
||||||
|
|
||||||
|
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.header.setLoading(true);
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
|
|
||||||
this.currentSessionsLoader((loaded, total) => {
|
const onProgress = (loaded: number, total: number) => {
|
||||||
if (this.scope !== "current") return;
|
if (scope !== this.scope) return;
|
||||||
|
if (seq !== undefined && seq !== this.allLoadSeq) return;
|
||||||
this.header.setProgress(loaded, total);
|
this.header.setProgress(loaded, total);
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
})
|
};
|
||||||
.then((sessions) => {
|
|
||||||
|
try {
|
||||||
|
const sessions = await (scope === "current"
|
||||||
|
? this.currentSessionsLoader(onProgress)
|
||||||
|
: this.allSessionsLoader(onProgress));
|
||||||
|
|
||||||
|
if (scope === "current") {
|
||||||
this.currentSessions = sessions;
|
this.currentSessions = sessions;
|
||||||
this.currentLoading = false;
|
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.header.setLoading(false);
|
||||||
this.sessionList.setSessions(sessions, false);
|
this.sessionList.setSessions(sessions, showCwd);
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
|
||||||
|
this.onCancel();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (scope === "current") {
|
||||||
this.currentLoading = false;
|
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);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
|
this.header.setLoading(false);
|
||||||
this.sessionList.setSessions([], false);
|
this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
|
||||||
this.requestRender();
|
|
||||||
});
|
if (reason === "initial") {
|
||||||
|
this.sessionList.setSessions([], showCwd);
|
||||||
|
}
|
||||||
|
this.requestRender();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleSortMode(): void {
|
private toggleSortMode(): void {
|
||||||
|
|
@ -641,6 +799,10 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshSessionsAfterMutation(): Promise<void> {
|
||||||
|
await this.loadScope(this.scope, "refresh");
|
||||||
|
}
|
||||||
|
|
||||||
private toggleScope(): void {
|
private toggleScope(): void {
|
||||||
if (this.scope === "current") {
|
if (this.scope === "current") {
|
||||||
this.scope = "all";
|
this.scope = "all";
|
||||||
|
|
@ -653,55 +815,17 @@ export class SessionSelectorComponent extends Container implements Focusable {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.header.setLoading(true);
|
if (!this.allLoading) {
|
||||||
this.sessionList.setSessions([], true);
|
void this.loadScope("all", "toggle");
|
||||||
this.requestRender();
|
}
|
||||||
|
return;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.scope = "current";
|
||||||
|
this.header.setScope(this.scope);
|
||||||
|
this.header.setLoading(this.currentLoading);
|
||||||
|
this.sessionList.setSessions(this.currentSessions ?? [], false);
|
||||||
|
this.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSessionList(): SessionList {
|
getSessionList(): SessionList {
|
||||||
|
|
|
||||||
|
|
@ -3460,9 +3460,19 @@ export class InteractiveMode {
|
||||||
void this.shutdown();
|
void this.shutdown();
|
||||||
},
|
},
|
||||||
() => this.ui.requestRender(),
|
() => 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(),
|
this.sessionManager.getSessionFile(),
|
||||||
);
|
);
|
||||||
return { component: selector, focus: selector.getSessionList() };
|
return { component: selector, focus: selector };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
101
packages/coding-agent/test/session-selector-rename.test.ts
Normal file
101
packages/coding-agent/test/session-selector-rename.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue