diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a71c6f58a..f63838626 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- Session selector (`/resume`) now supports named-only filter toggle (default `Ctrl+N`, configurable via `toggleSessionNamedFilter`) to show only named sessions ([#862](https://github.com/badlogic/pi-mono/issues/862)) + ## [0.50.3] - 2026-01-29 ### New Features diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index e2e8d0f15..80c19f985 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -3,6 +3,7 @@ */ import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; +import { KeybindingsManager } from "../core/keybindings.js"; import type { SessionInfo, SessionListProgress } from "../core/session-manager.js"; import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js"; @@ -15,6 +16,7 @@ export async function selectSession( ): Promise { return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); + const keybindings = KeybindingsManager.create(); let resolved = false; const selector = new SessionSelectorComponent( @@ -39,7 +41,7 @@ export async function selectSession( process.exit(0); }, () => ui.requestRender(), - { showRenameHint: false }, + { showRenameHint: false, keybindings }, ); ui.addChild(selector); diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 12661f791..d994f1535 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -25,6 +25,9 @@ export type AppAction = | "selectModel" | "expandTools" | "toggleThinking" + // Session selector-only action. Intentionally not treated as a globally reserved shortcut for + // extension shortcut conflict checks, since it only applies inside the /resume picker + | "toggleSessionNamedFilter" | "externalEditor" | "followUp" | "dequeue" @@ -56,6 +59,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record = { selectModel: "ctrl+l", expandTools: "ctrl+o", toggleThinking: "ctrl+t", + toggleSessionNamedFilter: "ctrl+n", externalEditor: "ctrl+g", followUp: "alt+enter", dequeue: "alt+up", @@ -82,6 +86,7 @@ const APP_ACTIONS: AppAction[] = [ "selectModel", "expandTools", "toggleThinking", + "toggleSessionNamedFilter", "externalEditor", "followUp", "dequeue", diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts index b77d2f882..a2975d340 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts @@ -3,6 +3,8 @@ import type { SessionInfo } from "../../../core/session-manager.js"; export type SortMode = "recent" | "relevance"; +export type NameFilter = "all" | "named"; + export interface ParsedSearchQuery { mode: "tokens" | "regex"; tokens: { kind: "fuzzy" | "phrase"; value: string }[]; @@ -25,6 +27,15 @@ function getSessionSearchText(session: SessionInfo): string { return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`; } +function hasSessionName(session: SessionInfo): boolean { + return !!session.name?.trim(); +} + +function matchesNameFilter(session: SessionInfo, filter: NameFilter): boolean { + if (filter === "all") return true; + return hasSessionName(session); +} + export function parseSearchQuery(query: string): ParsedSearchQuery { const trimmed = query.trim(); if (!trimmed) { @@ -142,9 +153,17 @@ export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): M return { matches: true, score: totalScore }; } -export function filterAndSortSessions(sessions: SessionInfo[], query: string, sortMode: SortMode): SessionInfo[] { +export function filterAndSortSessions( + sessions: SessionInfo[], + query: string, + sortMode: SortMode, + nameFilter: NameFilter = "all", +): SessionInfo[] { + // Apply name filter first. + const nameFiltered = nameFilter === "all" ? sessions : sessions.filter((s) => matchesNameFilter(s, nameFilter)); + const trimmed = query.trim(); - if (!trimmed) return sessions; + if (!trimmed) return nameFiltered; const parsed = parseSearchQuery(query); if (parsed.error) return []; @@ -152,7 +171,7 @@ export function filterAndSortSessions(sessions: SessionInfo[], query: string, so // Recent mode: filter only, keep incoming order. if (sortMode === "recent") { const filtered: SessionInfo[] = []; - for (const s of sessions) { + for (const s of nameFiltered) { const res = matchSession(s, parsed); if (res.matches) filtered.push(s); } @@ -161,7 +180,7 @@ export function filterAndSortSessions(sessions: SessionInfo[], query: string, so // Relevance mode: sort by score, tie-break by modified desc. const scored: { session: SessionInfo; score: number }[] = []; - for (const s of sessions) { + for (const s of nameFiltered) { const res = matchSession(s, parsed); if (!res.matches) continue; scored.push({ session: s, score: res.score }); 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 49f6c8a04..1df07076f 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -14,11 +14,12 @@ import { truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui"; +import { KeybindingsManager } from "../../../core/keybindings.js"; import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint } from "./keybinding-hints.js"; -import { filterAndSortSessions, type SortMode } from "./session-selector-search.js"; +import { appKey, appKeyHint, keyHint } from "./keybinding-hints.js"; +import { filterAndSortSessions, type NameFilter, type SortMode } from "./session-selector-search.js"; type SessionScope = "current" | "all"; @@ -50,6 +51,8 @@ function formatSessionDate(date: Date): string { class SessionSelectorHeader implements Component { private scope: SessionScope; private sortMode: SortMode; + private nameFilter: NameFilter; + private keybindings: KeybindingsManager; private requestRender: () => void; private loading = false; private loadProgress: { loaded: number; total: number } | null = null; @@ -59,9 +62,17 @@ class SessionSelectorHeader implements Component { private statusTimeout: ReturnType | null = null; private showRenameHint = false; - constructor(scope: SessionScope, sortMode: SortMode, requestRender: () => void) { + constructor( + scope: SessionScope, + sortMode: SortMode, + nameFilter: NameFilter, + keybindings: KeybindingsManager, + requestRender: () => void, + ) { this.scope = scope; this.sortMode = sortMode; + this.nameFilter = nameFilter; + this.keybindings = keybindings; this.requestRender = requestRender; } @@ -73,6 +84,10 @@ class SessionSelectorHeader implements Component { this.sortMode = sortMode; } + setNameFilter(nameFilter: NameFilter): void { + this.nameFilter = nameFilter; + } + setLoading(loading: boolean): void { this.loading = loading; // Progress is scoped to the current load; clear whenever the loading state is set @@ -122,6 +137,9 @@ class SessionSelectorHeader implements Component { const sortLabel = this.sortMode === "recent" ? "Recent" : "Fuzzy"; const sortText = theme.fg("muted", "Sort: ") + theme.fg("accent", sortLabel); + const nameLabel = this.nameFilter === "all" ? "All" : "Named"; + const nameText = theme.fg("muted", "Name: ") + theme.fg("accent", nameLabel); + let scopeText: string; if (this.loading) { const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "..."; @@ -132,7 +150,7 @@ class SessionSelectorHeader implements Component { scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`; } - const rightText = truncateToWidth(`${scopeText} ${sortText}`, width, ""); + const rightText = truncateToWidth(`${scopeText} ${nameText} ${sortText}`, width, ""); const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1); const left = truncateToWidth(leftText, availableLeft, ""); const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText)); @@ -154,6 +172,7 @@ class SessionSelectorHeader implements Component { const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're: regex · "phrase" exact'); const hint2Parts = [ keyHint("toggleSessionSort", "sort"), + appKeyHint(this.keybindings, "toggleSessionNamedFilter", "named"), keyHint("deleteSession", "delete"), keyHint("toggleSessionPath", `path ${pathState}`), ]; @@ -183,6 +202,8 @@ class SessionList implements Component, Focusable { private searchInput: Input; private showCwd = false; private sortMode: SortMode = "relevance"; + private nameFilter: NameFilter = "all"; + private keybindings: KeybindingsManager; private showPath = false; private confirmingDeletePath: string | null = null; private currentSessionFilePath?: string; @@ -191,6 +212,7 @@ class SessionList implements Component, Focusable { public onExit: () => void = () => {}; public onToggleScope?: () => void; public onToggleSort?: () => void; + public onToggleNameFilter?: () => void; public onTogglePath?: (showPath: boolean) => void; public onDeleteConfirmationChange?: (path: string | null) => void; public onDeleteSession?: (sessionPath: string) => Promise; @@ -208,12 +230,21 @@ class SessionList implements Component, Focusable { this.searchInput.focused = value; } - constructor(sessions: SessionInfo[], showCwd: boolean, sortMode: SortMode, currentSessionFilePath?: string) { + constructor( + sessions: SessionInfo[], + showCwd: boolean, + sortMode: SortMode, + nameFilter: NameFilter, + keybindings: KeybindingsManager, + currentSessionFilePath?: string, + ) { this.allSessions = sessions; this.filteredSessions = sessions; this.searchInput = new Input(); this.showCwd = showCwd; this.sortMode = sortMode; + this.nameFilter = nameFilter; + this.keybindings = keybindings; this.currentSessionFilePath = currentSessionFilePath; // Handle Enter in search input - select current item @@ -232,6 +263,11 @@ class SessionList implements Component, Focusable { this.filterSessions(this.searchInput.getValue()); } + setNameFilter(nameFilter: NameFilter): void { + this.nameFilter = nameFilter; + this.filterSessions(this.searchInput.getValue()); + } + setSessions(sessions: SessionInfo[], showCwd: boolean): void { this.allSessions = sessions; this.showCwd = showCwd; @@ -239,7 +275,7 @@ class SessionList implements Component, Focusable { } private filterSessions(query: string): void { - this.filteredSessions = filterAndSortSessions(this.allSessions, query, this.sortMode); + this.filteredSessions = filterAndSortSessions(this.allSessions, query, this.sortMode, this.nameFilter); this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); } @@ -271,18 +307,23 @@ class SessionList implements Component, Focusable { lines.push(""); // Blank line after search if (this.filteredSessions.length === 0) { - if (this.showCwd) { + let emptyMsg: string; + if (this.nameFilter === "named") { + const toggleKey = appKey(this.keybindings, "toggleSessionNamedFilter"); + // Name filter is active - hint to clear it + if (this.showCwd) { + emptyMsg = ` No named sessions found. Press ${toggleKey} to show all.`; + } else { + emptyMsg = ` No named sessions in current folder. Press ${toggleKey} to show all, or Tab to view all.`; + } + } else if (this.showCwd) { // "All" scope - no sessions anywhere that match filter - lines.push(theme.fg("muted", truncateToWidth(" No sessions found", width, "…"))); + emptyMsg = " No sessions found"; } else { // "Current folder" scope - hint to try "all" - lines.push( - theme.fg( - "muted", - truncateToWidth(" No sessions in current folder. Press Tab to view all.", width, "…"), - ), - ); + emptyMsg = " No sessions in current folder. Press Tab to view all."; } + lines.push(theme.fg("muted", truncateToWidth(emptyMsg, width, "…"))); return lines; } @@ -388,6 +429,12 @@ class SessionList implements Component, Focusable { return; } + // Toggle named-only filter (configurable) + if (this.keybindings.matches(keyData, "toggleSessionNamedFilter")) { + this.onToggleNameFilter?.(); + return; + } + // Ctrl+P: toggle path display if (kb.matches(keyData, "toggleSessionPath")) { this.showPath = !this.showPath; @@ -523,8 +570,10 @@ export class SessionSelectorComponent extends Container implements Focusable { private canRename = true; private sessionList: SessionList; private header: SessionSelectorHeader; + private keybindings: KeybindingsManager; private scope: SessionScope = "current"; private sortMode: SortMode = "relevance"; + private nameFilter: NameFilter = "all"; private currentSessions: SessionInfo[] | null = null; private allSessions: SessionInfo[] | null = null; private currentSessionsLoader: SessionsLoader; @@ -568,6 +617,15 @@ export class SessionSelectorComponent extends Container implements Focusable { this.addChild(new DynamicBorder((s) => theme.fg("accent", s))); } + constructor( + currentSessionsLoader: SessionsLoader, + allSessionsLoader: SessionsLoader, + onSelect: (sessionPath: string) => void, + onCancel: () => void, + onExit: () => void, + requestRender: () => void, + currentSessionFilePath?: string, + ); constructor( currentSessionsLoader: SessionsLoader, allSessionsLoader: SessionsLoader, @@ -578,22 +636,58 @@ export class SessionSelectorComponent extends Container implements Focusable { options?: { renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; showRenameHint?: boolean; + keybindings?: KeybindingsManager; }, currentSessionFilePath?: string, + ); + constructor( + currentSessionsLoader: SessionsLoader, + allSessionsLoader: SessionsLoader, + onSelect: (sessionPath: string) => void, + onCancel: () => void, + onExit: () => void, + requestRender: () => void, + arg7?: + | { + renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; + showRenameHint?: boolean; + keybindings?: KeybindingsManager; + } + | string, + arg8?: string, ) { super(); + + const options = typeof arg7 === "string" ? undefined : arg7; + const currentSessionFilePath = typeof arg7 === "string" ? arg7 : arg8; + + this.keybindings = options?.keybindings ?? KeybindingsManager.create(); + this.currentSessionsLoader = currentSessionsLoader; this.allSessionsLoader = allSessionsLoader; this.onCancel = onCancel; this.requestRender = requestRender; - this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender); + this.header = new SessionSelectorHeader( + this.scope, + this.sortMode, + this.nameFilter, + this.keybindings, + this.requestRender, + ); 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.sessionList = new SessionList( + [], + false, + this.sortMode, + this.nameFilter, + this.keybindings, + currentSessionFilePath, + ); this.buildBaseLayout(this.sessionList); @@ -617,6 +711,7 @@ export class SessionSelectorComponent extends Container implements Focusable { }; this.sessionList.onToggleScope = () => this.toggleScope(); this.sessionList.onToggleSort = () => this.toggleSortMode(); + this.sessionList.onToggleNameFilter = () => this.toggleNameFilter(); this.sessionList.onRenameSession = (sessionPath) => { if (!renameSession) return; if (this.scope === "current" && this.currentLoading) return; @@ -799,6 +894,13 @@ export class SessionSelectorComponent extends Container implements Focusable { this.requestRender(); } + private toggleNameFilter(): void { + this.nameFilter = this.nameFilter === "all" ? "named" : "all"; + this.header.setNameFilter(this.nameFilter); + this.sessionList.setNameFilter(this.nameFilter); + this.requestRender(); + } + private async refreshSessionsAfterMutation(): Promise { await this.loadScope(this.scope, "refresh"); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index cb6f7bc4b..731727b70 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3492,8 +3492,8 @@ export class InteractiveMode { mgr.appendSessionInfo(next); }, showRenameHint: true, + keybindings: this.keybindings, }, - this.sessionManager.getSessionFile(), ); return { component: selector, focus: selector }; diff --git a/packages/coding-agent/test/session-selector-path-delete.test.ts b/packages/coding-agent/test/session-selector-path-delete.test.ts index f1b2e96ac..327e49500 100644 --- a/packages/coding-agent/test/session-selector-path-delete.test.ts +++ b/packages/coding-agent/test/session-selector-path-delete.test.ts @@ -1,4 +1,5 @@ import { beforeAll, describe, expect, it } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.js"; 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"; @@ -43,6 +44,7 @@ const CTRL_D = "\x04"; const CTRL_BACKSPACE = "\x1b[127;5u"; describe("session selector path/delete interactions", () => { + const keybindings = KeybindingsManager.inMemory(); beforeAll(() => { // session selector uses the global theme instance initTheme("dark"); @@ -57,6 +59,7 @@ describe("session selector path/delete interactions", () => { () => {}, () => {}, () => {}, + { keybindings }, ); await flushPromises(); @@ -80,6 +83,7 @@ describe("session selector path/delete interactions", () => { () => {}, () => {}, () => {}, + { keybindings }, ); await flushPromises(); @@ -103,6 +107,7 @@ describe("session selector path/delete interactions", () => { () => {}, () => {}, () => {}, + { keybindings }, ); await flushPromises(); @@ -138,6 +143,7 @@ describe("session selector path/delete interactions", () => { () => {}, () => {}, () => {}, + { keybindings }, ); await flushPromises(); @@ -169,6 +175,7 @@ describe("session selector path/delete interactions", () => { () => {}, () => {}, () => {}, + { keybindings }, ); await flushPromises(); diff --git a/packages/coding-agent/test/session-selector-search.test.ts b/packages/coding-agent/test/session-selector-search.test.ts index 00d666066..7e7272797 100644 --- a/packages/coding-agent/test/session-selector-search.test.ts +++ b/packages/coding-agent/test/session-selector-search.test.ts @@ -124,4 +124,72 @@ describe("session selector search", () => { const result = filterAndSortSessions(sessions, "re:(", "recent"); expect(result).toEqual([]); }); + + describe("name filter", () => { + const sessions: SessionInfo[] = [ + makeSession({ + id: "named1", + name: "My Project", + modified: new Date("2026-01-03T00:00:00.000Z"), + allMessagesText: "blueberry", + }), + makeSession({ + id: "named2", + name: "Another Named", + modified: new Date("2026-01-02T00:00:00.000Z"), + allMessagesText: "blueberry", + }), + makeSession({ + id: "other1", + modified: new Date("2026-01-04T00:00:00.000Z"), + allMessagesText: "blueberry", + }), + makeSession({ + id: "other2", + modified: new Date("2026-01-01T00:00:00.000Z"), + allMessagesText: "blueberry", + }), + ]; + + it("returns all sessions when nameFilter is 'all'", () => { + const result = filterAndSortSessions(sessions, "", "recent", "all"); + expect(result.map((s) => s.id)).toEqual(["named1", "named2", "other1", "other2"]); + }); + + it("returns only named sessions when nameFilter is 'named'", () => { + const result = filterAndSortSessions(sessions, "", "recent", "named"); + expect(result.map((s) => s.id)).toEqual(["named1", "named2"]); + }); + + it("applies name filter before search query", () => { + const result = filterAndSortSessions(sessions, "blueberry", "recent", "named"); + expect(result.map((s) => s.id)).toEqual(["named1", "named2"]); + }); + + it("excludes whitespace-only names from named filter", () => { + const sessionsWithWhitespace: SessionInfo[] = [ + makeSession({ + id: "whitespace", + name: " ", + modified: new Date("2026-01-01T00:00:00.000Z"), + allMessagesText: "test", + }), + makeSession({ + id: "empty", + name: "", + modified: new Date("2026-01-02T00:00:00.000Z"), + allMessagesText: "test", + }), + makeSession({ + id: "named", + name: "Real Name", + modified: new Date("2026-01-03T00:00:00.000Z"), + allMessagesText: "test", + }), + ]; + + const result = filterAndSortSessions(sessionsWithWhitespace, "", "recent", "named"); + expect(result.map((s) => s.id)).toEqual(["named"]); + }); + }); });