diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 86a68cd2..92d7c406 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ - Edit tool now uses fuzzy matching as fallback when exact match fails, tolerating trailing whitespace, smart quotes, Unicode dashes, and special spaces ([#713](https://github.com/badlogic/pi-mono/pull/713) by [@dannote](https://github.com/dannote)) - Support `APPEND_SYSTEM.md` to append instructions to the system prompt ([#716](https://github.com/badlogic/pi-mono/pull/716) by [@tallshort](https://github.com/tallshort)) +- Session picker search: Ctrl+R toggles sorting between fuzzy match (default) and most recent; supports quoted phrase matching and `re:` regex mode ([#731](https://github.com/badlogic/pi-mono/pull/731) by [@ogulcancelik](https://github.com/ogulcancelik)) ### Changed 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 new file mode 100644 index 00000000..b77d2f88 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts @@ -0,0 +1,176 @@ +import { fuzzyMatch } from "@mariozechner/pi-tui"; +import type { SessionInfo } from "../../../core/session-manager.js"; + +export type SortMode = "recent" | "relevance"; + +export interface ParsedSearchQuery { + mode: "tokens" | "regex"; + tokens: { kind: "fuzzy" | "phrase"; value: string }[]; + regex: RegExp | null; + /** If set, parsing failed and we should treat query as non-matching. */ + error?: string; +} + +export interface MatchResult { + matches: boolean; + /** Lower is better; only meaningful when matches === true */ + score: number; +} + +function normalizeWhitespaceLower(text: string): string { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} + +function getSessionSearchText(session: SessionInfo): string { + return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`; +} + +export function parseSearchQuery(query: string): ParsedSearchQuery { + const trimmed = query.trim(); + if (!trimmed) { + return { mode: "tokens", tokens: [], regex: null }; + } + + // Regex mode: re: + if (trimmed.startsWith("re:")) { + const pattern = trimmed.slice(3).trim(); + if (!pattern) { + return { mode: "regex", tokens: [], regex: null, error: "Empty regex" }; + } + try { + return { mode: "regex", tokens: [], regex: new RegExp(pattern, "i") }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { mode: "regex", tokens: [], regex: null, error: msg }; + } + } + + // Token mode with quote support. + // Example: foo "node cve" bar + const tokens: { kind: "fuzzy" | "phrase"; value: string }[] = []; + let buf = ""; + let inQuote = false; + let hadUnclosedQuote = false; + + const flush = (kind: "fuzzy" | "phrase"): void => { + const v = buf.trim(); + buf = ""; + if (!v) return; + tokens.push({ kind, value: v }); + }; + + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]!; + if (ch === '"') { + if (inQuote) { + flush("phrase"); + inQuote = false; + } else { + flush("fuzzy"); + inQuote = true; + } + continue; + } + + if (!inQuote && /\s/.test(ch)) { + flush("fuzzy"); + continue; + } + + buf += ch; + } + + if (inQuote) { + hadUnclosedQuote = true; + } + + // If quotes were unbalanced, fall back to plain whitespace tokenization. + if (hadUnclosedQuote) { + return { + mode: "tokens", + tokens: trimmed + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0) + .map((t) => ({ kind: "fuzzy" as const, value: t })), + regex: null, + }; + } + + flush(inQuote ? "phrase" : "fuzzy"); + + return { mode: "tokens", tokens, regex: null }; +} + +export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): MatchResult { + const text = getSessionSearchText(session); + + if (parsed.mode === "regex") { + if (!parsed.regex) { + return { matches: false, score: 0 }; + } + const idx = text.search(parsed.regex); + if (idx < 0) return { matches: false, score: 0 }; + return { matches: true, score: idx * 0.1 }; + } + + if (parsed.tokens.length === 0) { + return { matches: true, score: 0 }; + } + + let totalScore = 0; + let normalizedText: string | null = null; + + for (const token of parsed.tokens) { + if (token.kind === "phrase") { + if (normalizedText === null) { + normalizedText = normalizeWhitespaceLower(text); + } + const phrase = normalizeWhitespaceLower(token.value); + if (!phrase) continue; + const idx = normalizedText.indexOf(phrase); + if (idx < 0) return { matches: false, score: 0 }; + totalScore += idx * 0.1; + continue; + } + + const m = fuzzyMatch(token.value, text); + if (!m.matches) return { matches: false, score: 0 }; + totalScore += m.score; + } + + return { matches: true, score: totalScore }; +} + +export function filterAndSortSessions(sessions: SessionInfo[], query: string, sortMode: SortMode): SessionInfo[] { + const trimmed = query.trim(); + if (!trimmed) return sessions; + + const parsed = parseSearchQuery(query); + if (parsed.error) return []; + + // Recent mode: filter only, keep incoming order. + if (sortMode === "recent") { + const filtered: SessionInfo[] = []; + for (const s of sessions) { + const res = matchSession(s, parsed); + if (res.matches) filtered.push(s); + } + return filtered; + } + + // Relevance mode: sort by score, tie-break by modified desc. + const scored: { session: SessionInfo; score: number }[] = []; + for (const s of sessions) { + const res = matchSession(s, parsed); + if (!res.matches) continue; + scored.push({ session: s, score: res.score }); + } + + scored.sort((a, b) => { + if (a.score !== b.score) return a.score - b.score; + return b.session.modified.getTime() - a.session.modified.getTime(); + }); + + return scored.map((r) => r.session); +} 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 911291da..191066d8 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -2,9 +2,9 @@ import * as os from "node:os"; import { type Component, Container, - fuzzyFilter, getEditorKeybindings, Input, + matchesKey, Spacer, truncateToWidth, visibleWidth, @@ -12,6 +12,7 @@ import { import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +import { filterAndSortSessions, type SortMode } from "./session-selector-search.js"; type SessionScope = "current" | "all"; @@ -42,17 +43,23 @@ function formatSessionDate(date: Date): string { class SessionSelectorHeader implements Component { private scope: SessionScope; + private sortMode: SortMode; private loading = false; private loadProgress: { loaded: number; total: number } | null = null; - constructor(scope: SessionScope) { + constructor(scope: SessionScope, sortMode: SortMode) { this.scope = scope; + this.sortMode = sortMode; } setScope(scope: SessionScope): void { this.scope = scope; } + setSortMode(sortMode: SortMode): void { + this.sortMode = sortMode; + } + setLoading(loading: boolean): void { this.loading = loading; if (!loading) { @@ -69,6 +76,10 @@ class SessionSelectorHeader implements Component { render(width: number): string[] { const title = this.scope === "current" ? "Resume Session (Current Folder)" : "Resume Session (All)"; const leftText = theme.bold(title); + + const sortLabel = this.sortMode === "recent" ? "Recent" : "Fuzzy"; + const sortText = theme.fg("muted", "Sort: ") + theme.fg("accent", sortLabel); + let scopeText: string; if (this.loading) { const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "..."; @@ -79,11 +90,12 @@ class SessionSelectorHeader implements Component { ? `${theme.fg("accent", "◉ Current Folder")}${theme.fg("muted", " | ○ All")}` : `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`; } - const rightText = truncateToWidth(scopeText, width, ""); + + const rightText = truncateToWidth(`${scopeText} ${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)); - const hint = theme.fg("muted", "Tab to toggle scope"); + const hint = theme.fg("muted", 'Tab: scope · Ctrl+R: sort · re: for regex · "phrase" for exact phrase'); return [`${left}${" ".repeat(spacing)}${rightText}`, hint]; } } @@ -97,17 +109,20 @@ class SessionList implements Component { private selectedIndex: number = 0; private searchInput: Input; private showCwd = false; + private sortMode: SortMode = "relevance"; public onSelect?: (sessionPath: string) => void; public onCancel?: () => void; public onExit: () => void = () => {}; public onToggleScope?: () => void; + public onToggleSort?: () => void; private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank) - constructor(sessions: SessionInfo[], showCwd: boolean) { + constructor(sessions: SessionInfo[], showCwd: boolean, sortMode: SortMode) { this.allSessions = sessions; this.filteredSessions = sessions; this.searchInput = new Input(); this.showCwd = showCwd; + this.sortMode = sortMode; // Handle Enter in search input - select current item this.searchInput.onSubmit = () => { @@ -120,6 +135,11 @@ class SessionList implements Component { }; } + setSortMode(sortMode: SortMode): void { + this.sortMode = sortMode; + this.filterSessions(this.searchInput.getValue()); + } + setSessions(sessions: SessionInfo[], showCwd: boolean): void { this.allSessions = sessions; this.showCwd = showCwd; @@ -127,11 +147,7 @@ class SessionList implements Component { } private filterSessions(query: string): void { - this.filteredSessions = fuzzyFilter( - this.allSessions, - query, - (session) => `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`, - ); + this.filteredSessions = filterAndSortSessions(this.allSessions, query, this.sortMode); this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); } @@ -219,6 +235,12 @@ class SessionList implements Component { } return; } + + if (matchesKey(keyData, "ctrl+r")) { + this.onToggleSort?.(); + return; + } + // Up arrow if (kb.matches(keyData, "selectUp")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); @@ -265,6 +287,7 @@ export class SessionSelectorComponent extends Container { private sessionList: SessionList; private header: SessionSelectorHeader; private scope: SessionScope = "current"; + private sortMode: SortMode = "relevance"; private currentSessions: SessionInfo[] | null = null; private allSessions: SessionInfo[] | null = null; private currentSessionsLoader: SessionsLoader; @@ -285,7 +308,7 @@ export class SessionSelectorComponent extends Container { this.allSessionsLoader = allSessionsLoader; this.onCancel = onCancel; this.requestRender = requestRender; - this.header = new SessionSelectorHeader(this.scope); + this.header = new SessionSelectorHeader(this.scope, this.sortMode); // Add header this.addChild(new Spacer(1)); @@ -295,11 +318,12 @@ export class SessionSelectorComponent extends Container { this.addChild(new Spacer(1)); // Create session list (starts empty, will be populated after load) - this.sessionList = new SessionList([], false); + this.sessionList = new SessionList([], false, this.sortMode); this.sessionList.onSelect = onSelect; this.sessionList.onCancel = onCancel; this.sessionList.onExit = onExit; this.sessionList.onToggleScope = () => this.toggleScope(); + this.sessionList.onToggleSort = () => this.toggleSortMode(); this.addChild(this.sessionList); @@ -325,6 +349,13 @@ export class SessionSelectorComponent extends Container { }); } + private toggleSortMode(): void { + this.sortMode = this.sortMode === "recent" ? "relevance" : "recent"; + this.header.setSortMode(this.sortMode); + this.sessionList.setSortMode(this.sortMode); + this.requestRender(); + } + private toggleScope(): void { if (this.scope === "current") { // Switching to "all" - load if not already loaded diff --git a/packages/coding-agent/test/session-selector-search.test.ts b/packages/coding-agent/test/session-selector-search.test.ts new file mode 100644 index 00000000..00d66606 --- /dev/null +++ b/packages/coding-agent/test/session-selector-search.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import type { SessionInfo } from "../src/core/session-manager.js"; +import { filterAndSortSessions } from "../src/modes/interactive/components/session-selector-search.js"; + +function makeSession( + overrides: Partial & { id: string; modified: Date; allMessagesText: string }, +): SessionInfo { + return { + path: `/tmp/${overrides.id}.jsonl`, + id: overrides.id, + cwd: overrides.cwd ?? "", + name: overrides.name, + created: overrides.created ?? new Date(0), + modified: overrides.modified, + messageCount: overrides.messageCount ?? 1, + firstMessage: overrides.firstMessage ?? "(no messages)", + allMessagesText: overrides.allMessagesText, + }; +} + +describe("session selector search", () => { + it("filters by quoted phrase with whitespace normalization", () => { + const sessions: SessionInfo[] = [ + makeSession({ + id: "a", + modified: new Date("2026-01-01T00:00:00.000Z"), + allMessagesText: "node\n\n cve was discussed", + }), + makeSession({ + id: "b", + modified: new Date("2026-01-02T00:00:00.000Z"), + allMessagesText: "node something else", + }), + ]; + + const result = filterAndSortSessions(sessions, '"node cve"', "recent"); + expect(result.map((s) => s.id)).toEqual(["a"]); + }); + + it("filters by regex (re:) and is case-insensitive", () => { + const sessions: SessionInfo[] = [ + makeSession({ + id: "a", + modified: new Date("2026-01-02T00:00:00.000Z"), + allMessagesText: "Brave is great", + }), + makeSession({ + id: "b", + modified: new Date("2026-01-03T00:00:00.000Z"), + allMessagesText: "bravery is not the same", + }), + ]; + + const result = filterAndSortSessions(sessions, "re:\\bbrave\\b", "recent"); + expect(result.map((s) => s.id)).toEqual(["a"]); + }); + + it("recent sort preserves input order", () => { + const sessions: SessionInfo[] = [ + makeSession({ + id: "newer", + modified: new Date("2026-01-03T00:00:00.000Z"), + allMessagesText: "brave", + }), + makeSession({ + id: "older", + modified: new Date("2026-01-01T00:00:00.000Z"), + allMessagesText: "brave", + }), + makeSession({ + id: "nomatch", + modified: new Date("2026-01-04T00:00:00.000Z"), + allMessagesText: "something else", + }), + ]; + + const result = filterAndSortSessions(sessions, '"brave"', "recent"); + expect(result.map((s) => s.id)).toEqual(["newer", "older"]); + }); + + it("relevance sort orders by score and tie-breaks by modified desc", () => { + const sessions: SessionInfo[] = [ + makeSession({ + id: "late", + modified: new Date("2026-01-03T00:00:00.000Z"), + allMessagesText: "xxxx brave", + }), + makeSession({ + id: "early", + modified: new Date("2026-01-01T00:00:00.000Z"), + allMessagesText: "brave xxxx", + }), + ]; + + const result1 = filterAndSortSessions(sessions, '"brave"', "relevance"); + expect(result1.map((s) => s.id)).toEqual(["early", "late"]); + + const tieSessions: SessionInfo[] = [ + makeSession({ + id: "newer", + modified: new Date("2026-01-03T00:00:00.000Z"), + allMessagesText: "brave", + }), + makeSession({ + id: "older", + modified: new Date("2026-01-01T00:00:00.000Z"), + allMessagesText: "brave", + }), + ]; + + const result2 = filterAndSortSessions(tieSessions, '"brave"', "relevance"); + expect(result2.map((s) => s.id)).toEqual(["newer", "older"]); + }); + + it("returns empty list for invalid regex", () => { + const sessions: SessionInfo[] = [ + makeSession({ + id: "a", + modified: new Date("2026-01-01T00:00:00.000Z"), + allMessagesText: "brave", + }), + ]; + + const result = filterAndSortSessions(sessions, "re:(", "recent"); + expect(result).toEqual([]); + }); +});