/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/examples/sdk/11-sessions.ts:32:	console.log(`  ${info.id.slice(0, 8)}... - "${info.firstMessage.slice(0, 30)}..."`);
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/docs/session-format.md:283:### SessionInfoEntry
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/docs/session-format.md:387:- `appendSessionInfo(name)` - Set session display name
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/CHANGELOG.md:2200:- `SessionManager.list()` and `SessionManager.listAll()` are now async, returning `Promise<SessionInfo[]>`. Callers must await them. ([#620](https://github.com/badlogic/pi-mono/pull/620) by [@tmustier](https://github.com/tmustier))
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/CHANGELOG.md:2205:- `SessionInfo.cwd` field containing the session's working directory (empty string for old sessions)
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js:324:async function buildSessionInfo(filePath) {
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js:346:        let firstMessage = "";
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js:367:            if (!firstMessage && message.role === "user") {
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js:368:                firstMessage = textContent;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js:383:            firstMessage: firstMessage || "(no messages)",
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js:402:            const info = await buildSessionInfo(file);
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js:646:    appendSessionInfo(name) {
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.js:1091:                const info = await buildSessionInfo(file);
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.d.ts:77:export interface SessionInfoEntry extends SessionEntryBase {
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.d.ts:101:export type SessionEntry = SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | CompactionEntry | BranchSummaryEntry | CustomEntry | CustomMessageEntry | LabelEntry | SessionInfoEntry;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.d.ts:121:export interface SessionInfo {
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.d.ts:133:    firstMessage: string;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.d.ts:210:    appendSessionInfo(name: string): string;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.d.ts:326:    static list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]>;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/session-manager.d.ts:331:    static listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]>;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/cli/session-picker.d.ts:4:import type { SessionInfo, SessionListProgress } from "../core/session-manager.js";
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/cli/session-picker.d.ts:5:type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/index.d.ts:16:export { type BranchSummaryEntry, buildSessionContext, type CompactionEntry, CURRENT_SESSION_VERSION, type CustomEntry, type CustomMessageEntry, type FileEntry, getLatestCompactionEntry, type ModelChangeEntry, migrateSessionEntries, type NewSessionOptions, parseSessionEntries, type SessionContext, type SessionEntry, type SessionEntryBase, type SessionHeader, type SessionInfo, type SessionInfoEntry, SessionManager, type SessionMessageEntry, type ThinkingLevelChangeEntry, } from "./core/session-manager.js";
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js:2153:        this.sessionManager.appendSessionInfo(name);
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector.d.ts:3:import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js";
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector.d.ts:36:    constructor(sessions: SessionInfo[], showCwd: boolean, sortMode: SortMode, nameFilter: NameFilter, keybindings: KeybindingsManager, currentSessionFilePath?: string);
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector.d.ts:39:    setSessions(sessions: SessionInfo[], showCwd: boolean): void;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector.d.ts:49:type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector.d.ts:53:export declare class SessionSelectorComponent extends Container implements Focusable {
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector-search.d.ts:1:import type { SessionInfo } from "../../../core/session-manager.js";
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector-search.d.ts:19:export declare function hasSessionName(session: SessionInfo): boolean;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector-search.d.ts:21:export declare function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): MatchResult;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector-search.d.ts:22:export declare function filterAndSortSessions(sessions: SessionInfo[], query: string, sortMode: SortMode, nameFilter?: NameFilter): SessionInfo[];
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector.js:369:            const displayText = session.name ?? session.firstMessage;
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/session-selector.js:566:export class SessionSelectorComponent extends Container {
/home/sebas/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/interactive-mode.js:3545:                    mgr.appendSessionInfo(next);


**Figuring out local extension patch**

I need to consider patching the local extension, possibly to override the firstMessage field in loaded sessions before passing it along. It might be a good idea to inspect the selector search, as it could involve using all available fields. I'll need to read relevant snippets to gather more details on how to do this effectively. It's important to get it right!


                emptyMessage = "  No sessions in current folder. Press Tab to view all.";
            }
            lines.push(theme.fg("muted", truncateToWidth(emptyMessage, width, "…")));
            return lines;
        }
        // Calculate visible range with scrolling
        const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible));
        const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length);
        // Render visible sessions (one line each with tree structure)
        for (let i = startIndex; i < endIndex; i++) {
            const node = this.filteredSessions[i];
            const session = node.session;
            const isSelected = i === this.selectedIndex;
            const isConfirmingDelete = session.path === this.confirmingDeletePath;
            const isCurrent = this.isCurrentSessionPath(session.path);
            // Build tree prefix
            const prefix = this.buildTreePrefix(node);
            // Session display text (name or first message)
            const hasName = !!session.name;
            const displayText = session.name ?? session.firstMessage;
            const normalizedMessage = displayText.replace(/[\x00-\x1f\x7f]/g, " ").trim();
            // Right side: message count and age
            const age = formatSessionDate(session.modified);
            const msgCount = String(session.messageCount);
            let rightPart = `${msgCount} ${age}`;
            if (this.showCwd && session.cwd) {
                rightPart = `${shortenPath(session.cwd)} ${rightPart}`;
            }
            if (this.showPath) {
                rightPart = `${shortenPath(session.path)} ${rightPart}`;
            }
            // Cursor
            const cursor = isSelected ? theme.fg("accent", "› ") : "  ";
            // Calculate available width for message
            const prefixWidth = visibleWidth(prefix);
            const rightWidth = visibleWidth(rightPart) + 2; // +2 for spacing
            const availableForMsg = width - 2 - prefixWidth - rightWidth; // -2 for cursor
            const truncatedMsg = truncateToWidth(normalizedMessage, Math.max(10, availableForMsg), "…");
            // Style message
            let messageColor = null;
            if (isConfirmingDelete) {
                messageColor = "error";
            }
            else if (isCurrent) {
                messageColor = "accent";
            }
            else if (hasName) {
                messageColor = "warning";
            }
            let styledMsg = messageColor ? theme.fg(messageColor, truncatedMsg) : truncatedMsg;
            if (isSelected) {
                styledMsg = theme.bold(styledMsg);
            }
            // Build line
            const leftPart = cursor + theme.fg("dim", prefix) + styledMsg;
            const leftWidth = visibleWidth(leftPart);
            const spacing = Math.max(1, width - leftWidth - visibleWidth(rightPart));
            const styledRight = theme.fg(isConfirmingDelete ? "error" : "dim", rightPart);
            let line = leftPart + " ".repeat(spacing) + styledRight;
            if (isSelected) {

[452 more lines in file. Use offset=410 to continue.]


import type { SessionInfo } from "../../../core/session-manager.js";
export type SortMode = "threaded" | "recent" | "relevance";
export type NameFilter = "all" | "named";
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;
}
export declare function hasSessionName(session: SessionInfo): boolean;
export declare function parseSearchQuery(query: string): ParsedSearchQuery;
export declare function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): MatchResult;
export declare function filterAndSortSessions(sessions: SessionInfo[], query: string, sortMode: SortMode, nameFilter?: NameFilter): SessionInfo[];
//# sourceMappingURL=session-selector-search.d.ts.map


export interface SessionInfo {
    path: string;
    id: string;
    /** Working directory where the session was started. Empty string for old sessions. */
    cwd: string;
    /** User-defined display name from session_info entries. */
    name?: string;
    /** Path to the parent session (if this session was forked). */
    parentSessionPath?: string;
    created: Date;
    modified: Date;
    messageCount: number;
    firstMessage: string;
    allMessagesText: string;
}
export type ReadonlySessionManager = Pick<SessionManager, "getCwd" | "getSessionDir" | "getSessionId" | "getSessionFile" | "getLeafId" | "getLeafEntry" | "getEntry" | "getLabel" | "getBranch" | "getHeader" | "getEntries" | "getTree" | "getSessionName">;
/** Exported for testing */
export declare function migrateSessionEntries(entries: FileEntry[]): void;
/** Exported for compaction.test.ts */
export declare function parseSessionEntries(content: string): FileEntry[];

[193 more lines in file. Use offset=141 to continue.]


            return null;
        const header = entries[0];
        if (header.type !== "session")
            return null;
        const stats = await stat(filePath);
        let messageCount = 0;
        let firstMessage = "";
        const allMessages = [];
        let name;
        for (const entry of entries) {
            // Extract session name (use latest, including explicit clears)
            if (entry.type === "session_info") {
                const infoEntry = entry;
                name = infoEntry.name?.trim() || undefined;
            }
            if (entry.type !== "message")
                continue;
            messageCount++;
            const message = entry.message;
            if (!isMessageWithContent(message))
                continue;
            if (message.role !== "user" && message.role !== "assistant")
                continue;
            const textContent = extractTextContent(message);
            if (!textContent)
                continue;
            allMessages.push(textContent);
            if (!firstMessage && message.role === "user") {
                firstMessage = textContent;
            }
        }
        const cwd = typeof header.cwd === "string" ? header.cwd : "";
        const parentSessionPath = header.parentSession;
        const modified = getSessionModifiedDate(entries, header, stats.mtime);
        return {
            path: filePath,
            id: header.id,
            cwd,
            name,
            parentSessionPath,
            created: new Date(header.timestamp),
            modified,
            messageCount,
            firstMessage: firstMessage || "(no messages)",
            allMessagesText: allMessages.join(" "),
        };
    }
    catch {
        return null;
    }
}
async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progressTotal) {
    const sessions = [];
    if (!existsSync(dir)) {
        return sessions;
    }
    try {
        const dirEntries = await readdir(dir);
        const files = dirEntries.filter((f) => f.endsWith(".jsonl")).map((f) => join(dir, f));
        const total = progressTotal ?? files.length;

[710 more lines in file. Use offset=400 to continue.]


edit

import { readFileSync } from "node:fs";

import type { ExtensionAPI, ExtensionContext, SessionInfo } from "@mariozechner/pi-coding-agent";
import { getMarkdownTheme, SessionManager, SessionSelectorComponent } from "@mariozechner/pi-coding-agent";

import type { Focusable } from "@mariozechner/pi-tui";
import { Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";

function extractTextFromContent(content: unknown): string {
	if (typeof content === "string") {
		return content;
	}
	if (!Array.isArray(content)) {
		return "";
	}
	return content
		.map((part) => {
			if (typeof part === "string") {
				return part;
			}
			if (part && typeof part === "object" && "type" in part && (part as { type?: unknown }).type === "text") {
				const text = (part as { text?: unknown }).text;
				return typeof text === "string" ? text : "";
			}
			return "";
		})
		.join(" ");
}

function firstWords(text: string, count: number): string {
	return text
		.replace(/\s+/g, " ")
		.trim()
		.split(" ")
		.filter(Boolean)
		.slice(0, count)
		.join(" ");
}

function buildSessionLabelFromFirstTwoUserMessages(sessionPath: string): string | undefined {
	try {
		const lines = readFileSync(sessionPath, "utf8").split("\n");
		const userMessages: string[] = [];
		for (const line of lines) {
			if (!line.trim()) continue;
			const entry = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } };
			if (entry.type !== "message" || entry.message?.role !== "user") continue;
			const text = extractTextFromContent(entry.message.content).trim();
			if (!text) continue;
			userMessages.push(text);
			if (userMessages.length >= 2) break;
		}
		if (userMessages.length === 0) return undefined;
		const first = firstWords(userMessages[0] || "", 5);
		const second = firstWords(userMessages[1] || "", 5);
		if (first && second) return `${first} / ${second}`;
		return first || second || undefined;
	} catch {
		return undefined;
	}
}

function withSessionListLabel(session: SessionInfo): SessionInfo {
	if (session.name) {
		return session;
	}
	const label = buildSessionLabelFromFirstTwoUserMessages(session.path);
	if (!label) {
		return session;
	}
	return {
		...session,
		firstMessage: label,
		allMessagesText: `${label} ${session.allMessagesText || ""}`.trim(),
	};
}

export const buildPreviewLines = (session: SessionInfo): string[] => {
	const text = session.allMessagesText || session.firstMessage || "";
	if (!text.trim()) {
		return [];
	}

	const rawLines = text.split("\n");
	const maxLines = 1200;
	const slice = rawLines.length > maxLines ? rawLines.slice(rawLines.length - maxLines) : rawLines;

	return slice.map((line) => line.replace(/\s+$/g, ""));
};

	const recordSessions = (sessions: SessionInfo[]) => {
		for (const session of sessions) {
			sessionByPath.set(session.path, session);
		}
	};

	const recordSessions = (sessions: SessionInfo[]) => {
		for (const session of sessions) {
			sessionByPath.set(session.path, withSessionListLabel(session));
		}
	};

	const currentSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => {
		const sessions = await SessionManager.list(currentCwd, undefined, onProgress);
		recordSessions(sessions);
		return sessions;
	};

	const currentSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => {
		const sessions = (await SessionManager.list(currentCwd, undefined, onProgress)).map(withSessionListLabel);
		recordSessions(sessions);
		return sessions;
	};

	const allSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => {
		const sessions = await SessionManager.listAll(onProgress);
		recordSessions(sessions);
		return sessions;
	};

	const allSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => {
		const sessions = (await SessionManager.listAll(onProgress)).map(withSessionListLabel);
		recordSessions(sessions);
		return sessions;
	};


Successfully replaced 5 block(s) in /home/sebas/.npm-global/lib/node_modules/pi-session-switch/extensions/session-switch/picker.ts.

+  1 import { readFileSync } from "node:fs";
+  2
   1 import type { ExtensionAPI, ExtensionContext, SessionInfo } from "@mariozechner/pi-coding-agent";
   2 import { getMarkdownTheme, SessionManager, SessionSelectorComponent } from "@mariozechner/pi-coding-agent";
   3
   4 import type { Focusable } from "@mariozechner/pi-tui";
     ...
  31 	const maxOffset = Math.max(0, totalLines - height);
  32 	return Math.max(0, Math.min(scrollFromBottom, maxOffset));
  33 };
  34
+ 37 function extractTextFromContent(content: unknown): string {
+ 38 	if (typeof content === "string") {
+ 39 		return content;
+ 40 	}
+ 41 	if (!Array.isArray(content)) {
+ 42 		return "";
+ 43 	}
+ 44 	return content
+ 45 		.map((part) => {
+ 46 			if (typeof part === "string") {
+ 47 				return part;
+ 48 			}
+ 49 			if (part && typeof part === "object" && "type" in part && (part as { type?: unknown }).type === "text") {
+ 50 				const text = (part as { text?: unknown }).text;
+ 51 				return typeof text === "string" ? text : "";
+ 52 			}
+ 53 			return "";
+ 54 		})
+ 55 		.join(" ");
+ 56 }
+ 57
+ 58 function firstWords(text: string, count: number): string {
+ 59 	return text
+ 60 		.replace(/\s+/g, " ")
+ 61 		.trim()
+ 62 		.split(" ")
+ 63 		.filter(Boolean)
+ 64 		.slice(0, count)
+ 65 		.join(" ");
+ 66 }
+ 67
+ 68 function buildSessionLabelFromFirstTwoUserMessages(sessionPath: string): string | undefined {
+ 69 	try {
+ 70 		const lines = readFileSync(sessionPath, "utf8").split("\n");
+ 71 		const userMessages: string[] = [];
+ 72 		for (const line of lines) {
+ 73 			if (!line.trim()) continue;
+ 74 			const entry = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } };
+ 75 			if (entry.type !== "message" || entry.message?.role !== "user") continue;
+ 76 			const text = extractTextFromContent(entry.message.content).trim();
+ 77 			if (!text) continue;
+ 78 			userMessages.push(text);
+ 79 			if (userMessages.length >= 2) break;
+ 80 		}
+ 81 		if (userMessages.length === 0) return undefined;
+ 82 		const first = firstWords(userMessages[0] || "", 5);
+ 83 		const second = firstWords(userMessages[1] || "", 5);
+ 84 		if (first && second) return `${first} / ${second}`;
+ 85 		return first || second || undefined;
+ 86 	} catch {
+ 87 		return undefined;
+ 88 	}
+ 89 }
+ 90
+ 91 function withSessionListLabel(session: SessionInfo): SessionInfo {
+ 92 	if (session.name) {
+ 93 		return session;
+ 94 	}
+ 95 	const label = buildSessionLabelFromFirstTwoUserMessages(session.path);
+ 96 	if (!label) {
+ 97 		return session;
+ 98 	}
+ 99 	return {
+100 		...session,
+101 		firstMessage: label,
+102 		allMessagesText: `${label} ${session.allMessagesText || ""}`.trim(),
+103 	};
+104 }
+105
  35 export const buildPreviewLines = (session: SessionInfo): string[] => {
  36 	const text = session.allMessagesText || session.firstMessage || "";
  37 	if (!text.trim()) {
  38 		return [];
     ...
 236 	const currentSessionFilePath = ctx.sessionManager.getSessionFile();
 237 	const sessionByPath = new Map<string, SessionInfo>();
 238 	const recordSessions = (sessions: SessionInfo[]) => {
 239 		for (const session of sessions) {
-240 			sessionByPath.set(session.path, session);
+311 			sessionByPath.set(session.path, withSessionListLabel(session));
 241 		}
 242 	};
 243
 244 	const currentSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => {
-245 		const sessions = await SessionManager.list(currentCwd, undefined, onProgress);
+316 		const sessions = (await SessionManager.list(currentCwd, undefined, onProgress)).map(withSessionListLabel);
 246 		recordSessions(sessions);
 247 		return sessions;
 248 	};
 249
 250 	const allSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => {
-251 		const sessions = await SessionManager.listAll(onProgress);
+322 		const sessions = (await SessionManager.listAll(onProgress)).map(withSessionListLabel);
 252 		recordSessions(sessions);
 253 		return sessions;
 254 	};
 255
     ...


bash

cd /home/sebas/.npm-global/lib/node_modules/pi-session-switch && node --input-type=module -e "import('./extensions/session-switch/picker.ts').then(()=>console.log('ok')).catch(err=>{console.error(err);process.exit(1)})"


Error [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]: Stripping types is currently unsupported for files under node_modules, for "/home/sebas/.npm-global/lib/node_modules/pi-session-switch/extensions/session-switch/picker.ts"
    at stripTypeScriptModuleTypes (node:internal/modules/typescript:156:11)
    at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:170:30)
    at defaultGetFormat (node:internal/modules/esm/get_format:245:36)
    at defaultLoad (node:internal/modules/esm/load:98:16)
    at ModuleLoader.load (node:internal/modules/esm/loader:815:12)
    at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:594:31)
    at #createModuleJob (node:internal/modules/esm/loader:624:36)
    at #getJobFromResolveResult (node:internal/modules/esm/loader:343:34)
    at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:311:41)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:664:25) {
  code: 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'
}

Command exited with code 1


**Considering TypeScript transpilation options**

I'm thinking I might need an extension for runtime handling with TypeScript transpilation. Should I check the syntax using tsx or maybe run `tsc --noEmit`? It seems easiest to strip things by copying out or using sed. I wonder if the tsx package would be useful? For now, I’ll quickly run the command `npx tsc --noEmit picker.ts --module nodenext --target es2022` and see what happens.

cd /home/sebas/.npm-global/lib/node_modules/pi-session-switch/extensions/session-switch && npx -y tsc --noEmit --module nodenext --target es2022 picker.ts


                This is not the tsc command you are looking for

To get access to the TypeScript compiler, tsc, from the command line either:

- Use npm install typescript to first add TypeScript to your project before using npx
- Use yarn to avoid accidentally running code from un-installed packages

Command exited with code 1


**Inspecting file syntax**

I’m considering whether to use existing TSX since working with it might be easier. If I can do a manual parse with TypeScript, that could also work. I wonder if running a pi function might be feasible, but then again, maybe not. For now, I think it’s best to just inspect the file for any syntax errors and address those directly. That way, I can ensure everything is in good shape without overcomplicating things.


import { readFileSync } from "node:fs";

import type { ExtensionAPI, ExtensionContext, SessionInfo } from "@mariozechner/pi-coding-agent";
import { getMarkdownTheme, SessionManager, SessionSelectorComponent } from "@mariozechner/pi-coding-agent";

import type { Focusable } from "@mariozechner/pi-tui";
import { Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";

export type SessionPickerDismissReason = "cancel" | "exit";

export type SessionPickerResult =
	| { kind: "selected"; sessionPath: string }
	| { kind: "dismissed"; reason: SessionPickerDismissReason };

export type SessionPickerContext = Pick<ExtensionContext, "hasUI" | "cwd" | "sessionManager" | "ui">;

type PreviewCacheEntry = {
	lines: string[];
};

type RenderCacheEntry = {
	path: string;
	width: number;
	renderedLines: string[];
};

const PREVIEW_SCROLL_UP = "shift+up";
const PREVIEW_SCROLL_DOWN = "shift+down";
const PREVIEW_PAGE_UP = "shift+pageup";
const PREVIEW_PAGE_DOWN = "shift+pagedown";

export const clampPreviewScrollFromBottom = (scrollFromBottom: number, totalLines: number, height: number): number => {
	const maxOffset = Math.max(0, totalLines - height);
	return Math.max(0, Math.min(scrollFromBottom, maxOffset));
};

function extractTextFromContent(content: unknown): string {
	if (typeof content === "string") {
		return content;
	}
	if (!Array.isArray(content)) {
		return "";
	}
	return content
		.map((part) => {
			if (typeof part === "string") {
				return part;
			}
			if (part && typeof part === "object" && "type" in part && (part as { type?: unknown }).type === "text") {
				const text = (part as { text?: unknown }).text;
				return typeof text === "string" ? text : "";
			}
			return "";
		})
		.join(" ");
}

function firstWords(text: string, count: number): string {
	return text
		.replace(/\s+/g, " ")
		.trim()
		.split(" ")
		.filter(Boolean)
		.slice(0, count)
		.join(" ");
}

function buildSessionLabelFromFirstTwoUserMessages(sessionPath: string): string | undefined {
	try {
		const lines = readFileSync(sessionPath, "utf8").split("\n");
		const userMessages: string[] = [];
		for (const line of lines) {
			if (!line.trim()) continue;
			const entry = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } };
			if (entry.type !== "message" || entry.message?.role !== "user") continue;
			const text = extractTextFromContent(entry.message.content).trim();
			if (!text) continue;
			userMessages.push(text);
			if (userMessages.length >= 2) break;
		}
		if (userMessages.length === 0) return undefined;
		const first = firstWords(userMessages[0] || "", 5);
		const second = firstWords(userMessages[1] || "", 5);
		if (first && second) return `${first} / ${second}`;
		return first || second || undefined;
	} catch {
		return undefined;
	}
}

function withSessionListLabel(session: SessionInfo): SessionInfo {
	if (session.name) {
		return session;
	}
	const label = buildSessionLabelFromFirstTwoUserMessages(session.path);
	if (!label) {
		return session;
	}
	return {
		...session,
		firstMessage: label,
		allMessagesText: `${label} ${session.allMessagesText || ""}`.trim(),
	};
}

export const buildPreviewLines = (session: SessionInfo): string[] => {
	const text = session.allMessagesText || session.firstMessage || "";
	if (!text.trim()) {
		return [];
	}

	const rawLines = text.split("\n");
	const maxLines = 1200;
	const slice = rawLines.length > maxLines ? rawLines.slice(rawLines.length - maxLines) : rawLines;

	return slice.map((line) => line.replace(/\s+$/g, ""));
};

class ResumeOverlay implements Focusable {
	private selector: SessionSelectorComponent;
	private sessionByPath: Map<string, SessionInfo>;
	private getTermHeight: () => number;
	private requestRender: () => void;
	private theme: any;
	private previewCache = new Map<string, PreviewCacheEntry>();
	private renderCache: RenderCacheEntry | null = null;
	private previewScrollFromBottom = 0;
	private lastPreviewHeight = 0;
	private lastSelectedPath: string | undefined = undefined;

	_focused = false;
	get focused(): boolean {
		return this._focused;
	}
	set focused(v: boolean) {
		this._focused = v;
		this.selector.focused = v;
	}

	constructor(opts: {
		selector: SessionSelectorComponent;
		sessionByPath: Map<string, SessionInfo>;
		getTermHeight: () => number;
		requestRender: () => void;
		theme: any;
	}) {
		this.selector = opts.selector;
		this.sessionByPath = opts.sessionByPath;
		this.getTermHeight = opts.getTermHeight;
		this.requestRender = opts.requestRender;
		this.theme = opts.theme;
	}

	private getPreview(path: string): PreviewCacheEntry {
		const cached = this.previewCache.get(path);
		if (cached) {
			return cached;
		}

		const session = this.sessionByPath.get(path);
		const entry = { lines: session ? buildPreviewLines(session) : [] };
		this.previewCache.set(path, entry);
		return entry;
	}

	private renderBackground(selectedPath: string | undefined, width: number, height: number): string[] {
		const blank = Array.from({ length: height }, () => "");
		if (!selectedPath) {
			return blank;
		}

		const preview = this.getPreview(selectedPath);
		if (preview.lines.length === 0) {
			const mid = Math.floor(height / 2);
			blank[mid] = truncateToWidth(
				" ".repeat(Math.max(0, Math.floor((width - 22) / 2))) + this.theme.fg("dim", "(no session preview)"),
				width,
			);
			return blank;
		}

		let renderedLines: string[];
		if (this.renderCache && this.renderCache.path === selectedPath && this.renderCache.width === width) {
			renderedLines = this.renderCache.renderedLines;
		} else {
			const markdown = new Markdown(preview.lines.join("\n"), 0, 0, getMarkdownTheme());
			renderedLines = markdown.render(width);
			this.renderCache = { path: selectedPath, width, renderedLines };
		}

		const clampedScroll = clampPreviewScrollFromBottom(this.previewScrollFromBottom, renderedLines.length, height);
		this.previewScrollFromBottom = clampedScroll;
		const maxOffset = Math.max(0, renderedLines.length - height);
		const start = Math.max(0, maxOffset - clampedScroll);
		const end = Math.min(renderedLines.length, start + height);

		let visible = renderedLines.slice(start, end);
		const above = start;
		const below = renderedLines.length - end;

		if (height > 0) {
			if (above > 0) {
				const indicator = truncateToWidth(this.theme.fg("muted", `… ${above} line(s) above`), width);
				visible = height === 1 ? [indicator] : [indicator, ...visible.slice(0, height - 1)];
			}
			if (below > 0) {
				const indicator = truncateToWidth(this.theme.fg("muted", `… ${below} line(s) below`), width);
				visible = height === 1 ? [indicator] : [...visible.slice(0, height - 1), indicator];
			}
		}

		while (visible.length < height) {
			visible.unshift("");
		}
		return visible;
	}

	handleInput(data: string): void {
		if (matchesKey(data, PREVIEW_SCROLL_UP)) {
			this.previewScrollFromBottom += 1;

[146 more lines in file. Use offset=221 to continue.]


**Considering session management**

I'm thinking about mentioning the need to restart the session, considering that any packages might load dynamically with each run. Maybe the next command or session switch only works in a new process? If the current session already has the extension loaded, then the command might not pick up any changes. It might be good to tell the user to restart their device or start a new session, but can I test this?


Updated #1 (in_progress → completed)

Find old-session listing code and switch preview from first message to last message

User wants old sessions shown by last message instead of first message used currently.


Hecho.

Cambio aplicado en `pi-session-switch`:
- sesiones sin nombre ahora muestran:
  `5 palabras del 1er mensaje / 5 palabras del 2do`
- si solo hay un mensaje de usuario, muestra solo esas 5
- también lo agregué al texto buscable para que ayude al filtro

Archivo tocado:
- `~/.npm-global/lib/node_modules/pi-session-switch/extensions/session-switch/picker.ts`

Importante:
- la sesión actual ya cargó la extensión vieja
- probalo en una sesión nueva de `pi`, o reiniciá el cliente y usá `/switch-session`

Si querés, siguiente paso: lo muevo desde `node_modules` a una copia local en `pi-config` para que no se pierda al actualizar paquetes.


Parece andar bien. Ahora pensé que lo ibas a hacer sobre el comando resum. Este Pi Session Switch. Es una extensión que teníamos instalada previa.?


**Clarifying extension usage**

I need to explain that the extension simply mirrors the /resume option and isn't the actual /resume itself. It's important to be clear about that. Additionally, if the user wants, I might mention the possibility of patching the real package for a more comprehensive solution. But I want to ensure I communicate this in a way that's easy to understand, so I'll focus on simplifying my explanation as much as possible.

Sí.

Lo cambié en `pi-session-switch`, no en `/resume` nativo.

Qué pasó:
- `pi-session-switch` ya estaba instalado antes
- en tu config aparece como:
  `npm:pi-session-switch`
- esa extensión registra `/switch-session`
- y su UI dice explícito que “mirror /resume”

O sea:
- `/switch-session` = extensión
- `/resume` = comando nativo de Pi
- yo toqué la extensión porque ahí está el código accesible local y era el match más claro

Si querés que quede en `/resume` real, hay 2 caminos:
1. parchear Pi nativo
2. reemplazar tu flujo para usar siempre `/switch-session`

Mi recomendación:
- dejar `/switch-session` como picker mejorado
- o crear alias mental/operativo y usar ese
- tocar `/resume` nativo es más frágil contra updates

Si querés, ahora hago una de estas:
- parchear `/resume` real
- renombrar flujo para que quede más obvio que ese es el bueno
- mover este cambio fuera de `node_modules` para que no se pierda al actualizar


Podemos renombrar switch session a resume para que lo pise o en un update se va a cambiar. Al menos renombraría algo similar a resume, así aparece cuando busco "s".


todo

Vendor session-switch locally and add sresume alias

Move session-switch off npm package path so edits survive updates, and register a resume-like command that appears under s-search.


Created #2: Vendor session-switch locally and add sresume alias (pending)


Updated #2 (pending → in_progress)


bash

set -e
mkdir -p /home/sebas/pi-config/extensions/session-switch /home/sebas/pi-config/extensions/_shared
cp /home/sebas/.npm-global/lib/node_modules/pi-session-switch/extensions/session-switch/index.ts /home/sebas/pi-config/extensions/session-switch/index.ts
cp /home/sebas/.npm-global/lib/node_modules/pi-session-switch/extensions/session-switch/picker.ts /home/sebas/pi-config/extensions/session-switch/picker.ts
cp /home/sebas/.npm-global/lib/node_modules/pi-session-switch/extensions/session-switch/relaunch.ts /home/sebas/pi-config/extensions/session-switch/relaunch.ts
cp /home/sebas/.npm-global/lib/node_modules/pi-session-switch/extensions/_shared/pi-spawn.ts /home/sebas/pi-config/extensions/_shared/pi-spawn.ts
printf 'copied\n'


copied


import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";

import type { SessionPickerResult } from "./picker.ts";
import { openSessionSwitchPicker } from "./picker.ts";
import { executeStartupAction, resolveStartupAction, resolveStartupSessionTarget } from "./relaunch.ts";

export function resolveCommandPickerAction(
	result: SessionPickerResult,
): { kind: "noop" | "shutdown" } | { kind: "switch"; sessionPath: string } {
	if (result.kind === "dismissed") {
		return result.reason === "exit" ? { kind: "shutdown" } : { kind: "noop" };
	}

	return { kind: "switch", sessionPath: result.sessionPath };
}

async function runSessionSwitchCommand(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
	const result = await openSessionSwitchPicker(pi, ctx);
	const action = resolveCommandPickerAction(result);
	if (action.kind === "noop") {
		return;
	}
	if (action.kind === "shutdown") {
		ctx.shutdown();
		return;
	}

	const switchResult = await ctx.switchSession(action.sessionPath);
	if (switchResult.cancelled) {
		ctx.ui.notify("Session switch cancelled", "info");
	}
}

async function runSessionSwitchStartup(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
	const result = await openSessionSwitchPicker(pi, ctx);
	if (result.kind === "selected") {
		const target = resolveStartupSessionTarget(result.sessionPath);
		if ("warning" in target) {
			ctx.ui.notify(target.warning, "warning");
			return;
		}

		const action = resolveStartupAction(result, { cwd: target.cwd });
		executeStartupAction(ctx, action);
		return;
	}

	const action = resolveStartupAction(result, { cwd: ctx.cwd });
	executeStartupAction(ctx, action);
}

export default function sessionSwitchExtension(pi: ExtensionAPI) {
	pi.registerFlag("switch-session", {
		description: "Open the session-switch picker after startup, then relaunch into the selected session",
		type: "boolean",
		default: false,
	});

	pi.on("session_start", async (event, ctx) => {
		if (event.reason !== "startup" || !ctx.hasUI || pi.getFlag("switch-session") !== true) {
			return;
		}

		await runSessionSwitchStartup(pi, ctx);
	});

	pi.registerCommand("switch-session", {
		description: "Session picker (mirrors /resume) with live preview",
		handler: async (_args, ctx) => {
			await runSessionSwitchCommand(pi, ctx);
		},
	});
}


edit

export default function sessionSwitchExtension(pi: ExtensionAPI) {
	pi.registerFlag("switch-session", {
		description: "Open the session-switch picker after startup, then relaunch into the selected session",
		type: "boolean",
		default: false,
	});

	pi.on("session_start", async (event, ctx) => {
		if (event.reason !== "startup" || !ctx.hasUI || pi.getFlag("switch-session") !== true) {
			return;
		}

		await runSessionSwitchStartup(pi, ctx);
	});

	pi.registerCommand("switch-session", {
		description: "Session picker (mirrors /resume) with live preview",
		handler: async (_args, ctx) => {
			await runSessionSwitchCommand(pi, ctx);
		},
	});
}

export default function sessionSwitchExtension(pi: ExtensionAPI) {
	pi.registerFlag("switch-session", {
		description: "Open the session-switch picker after startup, then relaunch into the selected session",
		type: "boolean",
		default: false,
	});

	pi.on("session_start", async (event, ctx) => {
		if (event.reason !== "startup" || !ctx.hasUI || pi.getFlag("switch-session") !== true) {
			return;
		}

		await runSessionSwitchStartup(pi, ctx);
	});

	const registerPickerCommand = (name: string, description: string) => {
		pi.registerCommand(name, {
			description,
			handler: async (_args, ctx) => {
				await runSessionSwitchCommand(pi, ctx);
			},
		});
	};

	registerPickerCommand("switch-session", "Session picker with live preview");
	registerPickerCommand("sresume", "Session picker like /resume, easier to find under s");
}


Successfully replaced 1 block(s) in /home/sebas/pi-config/extensions/session-switch/index.ts.

    ...
 63
 64 		await runSessionSwitchStartup(pi, ctx);
 65 	});
 66
-67 	pi.registerCommand("switch-session", {
-68 		description: "Session picker (mirrors /resume) with live preview",
-69 		handler: async (_args, ctx) => {
-70 			await runSessionSwitchCommand(pi, ctx);
-71 		},
-72 	});
+67 	const registerPickerCommand = (name: string, description: string) => {
+68 		pi.registerCommand(name, {
+69 			description,
+70 			handler: async (_args, ctx) => {
+71 				await runSessionSwitchCommand(pi, ctx);
+72 			},
+73 		});
+74 	};
+75
+76 	registerPickerCommand("switch-session", "Session picker with live preview");
+77 	registerPickerCommand("sresume", "Session picker like /resume, easier to find under s");
 73 }


Successfully replaced 1 block(s) in /home/sebas/pi-config/settings.json.

    ...
 16   "packages": [
 17     "git:github.com/apmantza/pi-free",
 18     "npm:pi-btw",
 19     "../../../home/sebas/.agents/extensions/resume-hint/resume-hint.js",
-20     "npm:pi-session-switch",
+20     "/home/sebas/pi-config/extensions/session-switch/index.ts",
 21     "npm:@juicesharp/rpiv-todo",
 22     "/mnt/rpi/dev/pi-telegram-sebas"
 23   ]
 24 }


Updated #2 (in_progress → completed)


Sí. Ya lo dejé mejor.

Hecho:
- saqué la dependencia de `npm:pi-session-switch`
- la vendorié local en `~/pi-config/extensions/session-switch/`
- agregué alias:
  - `/switch-session`
  - `/sresume`

Cambié:
- `~/pi-config/settings.json`
- `~/pi-config/extensions/session-switch/index.ts`
- copiadas dependencias locales:
  - `picker.ts`
  - `relaunch.ts`
  - `_shared/pi-spawn.ts`

Resultado:
- ya no depende del paquete npm para este flujo
- no se pierde en update del paquete
- `/sresume` debería aparecer fácil cuando buscás con `s`

No hice `/resume` exacto:
- puede chocar con el comando nativo
- es más frágil
- `sresume` da casi lo mismo sin riesgo

Te falta:
- abrir sesión nueva de `pi` para que cargue la config nueva

Si querés, siguiente paso:
- agregar también `/sr`
- o intentar pisar `/resume` exacto y ver si Pi deja override.


nombralo 'resum' asi aparece antes de resume


**Considering command name changes**

I’m thinking about the local extension command name. The user wants it renamed from “sresume” to just “resum.” It seems like a straightforward request, so I’ll go ahead and make that change. It's important to keep things simple and clear, and using “resum” sounds concise. I’ll ensure that it reflects what the user prefers!

	registerPickerCommand("switch-session", "Session picker with live preview");
	registerPickerCommand("sresume", "Session picker like /resume, easier to find under s");

	registerPickerCommand("switch-session", "Session picker with live preview");
	registerPickerCommand("resum", "Session picker like /resume, sorted before it");


    ...
 73 		});
 74 	};
 75
 76 	registerPickerCommand("switch-session", "Session picker with live preview");
-77 	registerPickerCommand("sresume", "Session picker like /resume, easier to find under s");
+77 	registerPickerCommand("resum", "Session picker like /resume, sorted before it");
 78 }


Hecho.

Ahora los comandos son:
- `/switch-session`
- `/resum`

`/resum` debería quedar antes que `/resume`.

Tomará efecto en sesión nueva de `pi`.