pi-project-switcher 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/index.ts +289 -127
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,11 @@ A [pi coding agent](https://github.com/earendil-works/pi) extension to switch be
4
4
 
5
5
  ## What it does
6
6
 
7
- - **`/project`** — list all projects (direct subdirectories of the base dir) with git branch info, mark the active one
7
+ - **`/project`** — list all projects (direct subdirectories of the base dir) with git branch info, mark the active one. The output adapts to the surface:
8
+ - **native TUI**: a selection dialog; picking a project switches to it (dismissing shows the plain list)
9
+ - **Telegram bridge**: the reply in the chat shows one tappable button per project; clicking a button is exactly like typing `/project <name>`
10
+ - **other surfaces** (rpc/json/print): plain text list
11
+ - Note: for the Telegram buttons to work, this extension must be loaded **before** `pi-telegram-command-bridge` (package order in `~/.pi/agent/settings.json`) — the switcher needs to see the raw `[telegram] /project` dispatch before the bridge re-dispatches it.
8
12
  - **`/project <name>`** — switch the active project:
9
13
  - restores the project's last session if one is stored (see below)
10
14
  - persists across reloads (session entry)
@@ -51,7 +55,7 @@ Single-file TypeScript extension (`index.ts`), loaded directly by pi via jiti
51
55
 
52
56
  ```bash
53
57
  npm install
54
- npm test # vitest (17 tests)
58
+ npm test # vitest (45 tests)
55
59
  npm run typecheck
56
60
 
57
61
  # Run once without installing
package/index.ts CHANGED
@@ -173,6 +173,92 @@ function rememberSessionFile(project: string, absSessionFile: string): void {
173
173
  let activeProject: string | null = null;
174
174
  let config: Config | null = null;
175
175
 
176
+ // ── Telegram origin detection (input event + flag) ───────────────────────────
177
+
178
+ /**
179
+ * Matches a first line dispatched from the pi-telegram bridge: a
180
+ * `[telegram]` tag (optionally with attributes like `[telegram|thread:x]`)
181
+ * followed by a bare `/project` with no arguments.
182
+ */
183
+ const TELEGRAM_STATUS_RE = /^\[telegram(?:\|[^\]]*)?\]\s*\/project\s*$/;
184
+
185
+ /** How long an armed flag stays valid (ms). Guards against stale flags
186
+ * from dispatches that never reached the command handler. */
187
+ const TELEGRAM_FLAG_TTL_MS = 30_000;
188
+
189
+ /** Armed-at timestamp of a pending Telegram-originated status request, or null. */
190
+ let telegramStatusFlag: number | null = null;
191
+
192
+ function armTelegramStatusFlag(): void {
193
+ telegramStatusFlag = Date.now();
194
+ }
195
+
196
+ /** Consume the flag: returns true (and clears it) when armed and not expired. */
197
+ function consumeTelegramStatusFlag(): boolean {
198
+ if (telegramStatusFlag === null) return false;
199
+ const armed = telegramStatusFlag;
200
+ telegramStatusFlag = null;
201
+ return Date.now() - armed <= TELEGRAM_FLAG_TTL_MS;
202
+ }
203
+
204
+ function clearTelegramStatusFlag(): void {
205
+ telegramStatusFlag = null;
206
+ }
207
+
208
+ /**
209
+ * A project name that could alter button markup or the button prompt is
210
+ * rendered as plain list text only (never gets a button cell).
211
+ */
212
+ function isUnsafeButtonName(name: string): boolean {
213
+ return /[{}|`\\\n]/.test(name);
214
+ }
215
+
216
+ /**
217
+ * Build the follow-up prompt for a Telegram-originated status request.
218
+ * Embeds the authoritative project list and a pre-rendered telegram_button
219
+ * block the agent copies verbatim into its reply.
220
+ */
221
+ function buildTelegramStatusPrompt(projects: string[]): string {
222
+ const ordered = [...projects];
223
+ if (activeProject) {
224
+ const idx = ordered.indexOf(activeProject);
225
+ if (idx > 0) {
226
+ ordered.splice(idx, 1);
227
+ ordered.unshift(activeProject);
228
+ } else if (idx === -1) {
229
+ ordered.unshift(activeProject);
230
+ }
231
+ }
232
+
233
+ const listLines = ordered
234
+ .map((p) => ` ${p}${p === activeProject ? " ◀ active" : ""}`)
235
+ .join("\n");
236
+
237
+ const buttonCells = ordered
238
+ .filter((p) => !isUnsafeButtonName(p))
239
+ .map((p) =>
240
+ p === activeProject
241
+ ? `{✅ ${p} (active)|/project ${p}}`
242
+ : `{📁 ${p}|/project ${p}}`,
243
+ )
244
+ .join("\n");
245
+ const buttonBlock =
246
+ buttonCells.length > 0
247
+ ? "\n\n```telegram_button\n" + buttonCells + "\n```"
248
+ : "";
249
+
250
+ const current = activeProject ? `Active: ${activeProject}` : "No project active";
251
+
252
+ return (
253
+ `[project-switcher] The user ran /project via Telegram and expects the project list in the chat.\n` +
254
+ `Authoritative list (do not re-derive, do not add or remove entries):\n` +
255
+ `${current}\n\n${listLines}\n\n` +
256
+ `Reply in the chat with exactly this list (keep the active marker) followed by the button block below, ` +
257
+ `copied verbatim. Do not run any command, do not switch projects yourself, and add nothing else.${buttonBlock}`
258
+ );
259
+ }
260
+
261
+
176
262
  function getConfig(): Config {
177
263
  if (!config) {
178
264
  config = loadConfig();
@@ -209,9 +295,30 @@ function isSafeProjectName(name: string): boolean {
209
295
  return true;
210
296
  }
211
297
 
298
+ /**
299
+ * Plain text project list via the local UI notification channel —
300
+ * the unchanged default output for non-Telegram surfaces.
301
+ */
302
+ function notifyPlainProjectList(ctx: any, projects: string[]): void {
303
+ const current = activeProject ? `Active: ${activeProject}` : "No project active";
304
+ const lines = projects.map((p) => {
305
+ const branch = getGitBranch(projectPath(p));
306
+ const branchStr = branch ? ` [${branch}]` : "";
307
+ const marker = p === activeProject ? " ◀ active" : "";
308
+ return ` ${p}${branchStr}${marker}`;
309
+ });
310
+ ctx.ui.notify(
311
+ `${current}\n\nProjects under ${getConfig().baseDir}:\n${lines.join("\n")}`,
312
+ "info"
313
+ );
314
+ }
315
+
212
316
  export default function (pi: ExtensionAPI) {
213
317
  // ── Restore state on session start ──────────────────────────────────────
214
318
  pi.on("session_start", async (_event, ctx) => {
319
+ // Session switches reset any pending Telegram status flag
320
+ clearTelegramStatusFlag();
321
+
215
322
  for (const entry of ctx.sessionManager.getEntries()) {
216
323
  if (
217
324
  entry.type === "custom" &&
@@ -244,6 +351,28 @@ export default function (pi: ExtensionAPI) {
244
351
  }
245
352
  });
246
353
 
354
+ // ── Telegram origin detection (input event) ────────────────────────────────
355
+ //
356
+ // Prompts dispatched from the pi-telegram queue arrive with source
357
+ // "extension" and a `[telegram]`-tagged first line, BEFORE the command
358
+ // bridge re-dispatches the bare command line. Recognizing the raw
359
+ // dispatch here is the only reliable origin signal; the re-dispatched
360
+ // text is indistinguishable from a native TUI invocation.
361
+ //
362
+ // Requires this extension to be listed BEFORE the command bridge in the
363
+ // package order (the bridge's "handled" result would short-circuit the
364
+ // input chain before this handler runs otherwise). Wrong order degrades
365
+ // gracefully: the flag is never armed and the status falls back to the
366
+ // plain list.
367
+ pi.on("input", async (event) => {
368
+ if (event.source !== "extension") return;
369
+ const firstLine = event.text.split("\n", 1)[0];
370
+ if (TELEGRAM_STATUS_RE.test(firstLine.trim())) {
371
+ armTelegramStatusFlag();
372
+ }
373
+ // Never transform or handle: the command bridge owns the re-dispatch.
374
+ });
375
+
247
376
  // ── Inject project context into every agent turn ─────────────────────────
248
377
  pi.on("before_agent_start", async (event, _ctx) => {
249
378
  if (!activeProject || !isValidProject(activeProject)) {
@@ -262,6 +391,140 @@ export default function (pi: ExtensionAPI) {
262
391
  };
263
392
  });
264
393
 
394
+ // ── Shared switch flow ──────────────────────────────────────────────────
395
+ //
396
+ // Single implementation of /project <name> semantics, used by the typed
397
+ // argument path and the TUI selection dialog. `createOptIn` (trailing
398
+ // "!") only applies to the typed path; dialog users confirm interactively.
399
+ const switchToProject = async (rawName: string, ctx: any, createOptIn: boolean): Promise<void> => {
400
+ const name = rawName;
401
+
402
+ if (!isValidProject(name)) {
403
+ // Offer to create the folder and switch to it (never silently)
404
+ if (isSafeProjectName(name)) {
405
+ let create = false;
406
+ if (createOptIn) {
407
+ create = true;
408
+ } else if (ctx.hasUI) {
409
+ try {
410
+ create = await ctx.ui.confirm(
411
+ "Create project?",
412
+ `Project "${name}" does not exist. Create ${join(getConfig().baseDir, name)} and switch to it?`
413
+ );
414
+ } catch {
415
+ create = false;
416
+ }
417
+ }
418
+
419
+ if (create) {
420
+ const newPath = projectPath(name);
421
+ try {
422
+ mkdirSync(newPath, { recursive: false });
423
+ ctx.ui.notify(`Created project folder: ${newPath}`, "info");
424
+ } catch (err: any) {
425
+ ctx.ui.notify(
426
+ `Failed to create project folder ${newPath}: ${err?.message ?? err}`,
427
+ "error"
428
+ );
429
+ return;
430
+ }
431
+ // fall through: the folder now exists and the switch proceeds below
432
+ }
433
+ }
434
+
435
+ if (!isValidProject(name)) {
436
+ const available = discoverProjects(getConfig().baseDir).join(", ");
437
+ ctx.ui.notify(
438
+ `Unknown project: "${name}".\nAvailable: ${available || "(none)"}`,
439
+ "warning"
440
+ );
441
+ return;
442
+ }
443
+ }
444
+
445
+ if (name === activeProject) {
446
+ ctx.ui.notify(`Already on project: ${name}`, "info");
447
+ return;
448
+ }
449
+
450
+ const previous = activeProject;
451
+
452
+ // Remember the current session under the PREVIOUS project before switching
453
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
454
+ if (previous && currentSessionFile) {
455
+ rememberSessionFile(previous, currentSessionFile);
456
+ }
457
+
458
+ // Try to restore the target project's last session
459
+ const targetSession = getMappedSessionFile(name);
460
+
461
+ if (targetSession) {
462
+ // Persist the switch in the OLD session before replacing it
463
+ pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
464
+
465
+ const result = await ctx.switchSession(targetSession);
466
+ if (result.cancelled) {
467
+ // User cancelled; roll back in-memory state
468
+ activeProject = previous;
469
+ ctx.ui.notify(`Switch cancelled. Staying on ${previous ?? "no project"}.`, "info");
470
+ return;
471
+ }
472
+
473
+ // switchSession fires a new session_start, which restores state from
474
+ // the target session's entries (or auto-detects). Set it explicitly as
475
+ // a safety net in case the session has no project entry yet.
476
+ activeProject = name;
477
+ pi.setSessionName(name);
478
+ rememberSessionFile(name, targetSession);
479
+
480
+ const path = projectPath(name);
481
+ const branch = getGitBranch(path);
482
+ const branchStr = branch ? ` on branch \`${branch}\`` : "";
483
+ ctx.ui.notify(
484
+ `Switched to ${name} — session restored: ${basename(targetSession)}\n` +
485
+ `Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}`,
486
+ "info"
487
+ );
488
+ return;
489
+ }
490
+
491
+ // ── Fallback: no stored session (or file gone) -> same-session switch ─
492
+ activeProject = name;
493
+
494
+ // Persist to session
495
+ pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
496
+
497
+ // Update session name
498
+ pi.setSessionName(name);
499
+
500
+ const path = projectPath(name);
501
+ const branch = getGitBranch(path);
502
+ const branchStr = branch ? ` on branch \`${branch}\`` : "";
503
+ const fromStr = previous ? ` (was: ${previous})` : "";
504
+
505
+ // Current session identity — still valid on this path (no session replacement)
506
+ const currentFile = currentSessionFile ?? ctx.sessionManager.getSessionFile();
507
+ const sessionLine = currentFile
508
+ ? `Continuing session: ${basename(currentFile)}`
509
+ : "Continuing current session";
510
+
511
+ ctx.ui.notify(
512
+ `Switched to ${name}${fromStr} — first session in this project\n` +
513
+ `Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}\n` +
514
+ sessionLine,
515
+ "info"
516
+ );
517
+
518
+ // Announce to the agent so it operates in the new context
519
+ await ctx.waitForIdle();
520
+ pi.sendUserMessage(
521
+ `[Project switched to **${name}**]\n` +
522
+ `Working directory: \`${path}\`${branchStr}\n` +
523
+ `Please keep all file operations within this project from now on.`,
524
+ { deliverAs: "followUp" }
525
+ );
526
+ };
527
+
265
528
  // ── /project command ─────────────────────────────────────────────────────
266
529
  pi.registerCommand("project", {
267
530
  description: "Show or switch active project (/project [name])",
@@ -283,7 +546,7 @@ export default function (pi: ExtensionAPI) {
283
546
  name = name.slice(0, -1).trim();
284
547
  }
285
548
 
286
- // ── No arg: show status ──────────────────────────────────────────────
549
+ // ── No arg: surface-adaptive status ──────────────────────────────────
287
550
  if (!name) {
288
551
  const projects = discoverProjects(getConfig().baseDir);
289
552
  if (projects.length === 0) {
@@ -293,142 +556,41 @@ export default function (pi: ExtensionAPI) {
293
556
  );
294
557
  return;
295
558
  }
296
- const current = activeProject ? `Active: ${activeProject}` : "No project active";
297
- const lines = projects.map((p) => {
298
- const branch = getGitBranch(projectPath(p));
299
- const branchStr = branch ? ` [${branch}]` : "";
300
- const marker = p === activeProject ? " ◀ active" : "";
301
- return ` ${p}${branchStr}${marker}`;
302
- });
303
- ctx.ui.notify(`${current}\n\nProjects under ${getConfig().baseDir}:\n${lines.join("\n")}`, "info");
304
- return;
305
- }
306
-
307
- // ── Switch project ────────────────────────────────────────────────────
308
- if (!isValidProject(name)) {
309
- // Offer to create the folder and switch to it (never silently)
310
- if (isSafeProjectName(name)) {
311
- let create = false;
312
- if (createOptIn) {
313
- create = true;
314
- } else if (ctx.hasUI) {
315
- try {
316
- create = await ctx.ui.confirm(
317
- "Create project?",
318
- `Project "${name}" does not exist. Create ${join(getConfig().baseDir, name)} and switch to it?`
319
- );
320
- } catch {
321
- create = false;
322
- }
323
- }
324
559
 
325
- if (create) {
326
- const newPath = projectPath(name);
327
- try {
328
- mkdirSync(newPath, { recursive: false });
329
- ctx.ui.notify(`Created project folder: ${newPath}`, "info");
330
- } catch (err: any) {
331
- ctx.ui.notify(
332
- `Failed to create project folder ${newPath}: ${err?.message ?? err}`,
333
- "error"
334
- );
335
- return;
336
- }
337
- // fall through: the folder now exists and the switch proceeds below
338
- }
339
- }
340
-
341
- if (!isValidProject(name)) {
342
- const available = discoverProjects(getConfig().baseDir).join(", ");
343
- ctx.ui.notify(
344
- `Unknown project: "${name}".\nAvailable: ${available || "(none)"}`,
345
- "warning"
346
- );
560
+ const isTelegramOrigin = consumeTelegramStatusFlag();
561
+
562
+ if (isTelegramOrigin) {
563
+ // Plain list for the local surface (parity with today) …
564
+ notifyPlainProjectList(ctx, projects);
565
+ // … then a follow-up turn whose reply reaches the Telegram chat,
566
+ // with one button per project. The turn also settles the command
567
+ // bridge's pending dispatch (its agent_start hook clears the
568
+ // pending settle entry first).
569
+ await ctx.waitForIdle();
570
+ pi.sendUserMessage(buildTelegramStatusPrompt(projects), {
571
+ deliverAs: "followUp",
572
+ });
347
573
  return;
348
574
  }
349
- }
350
575
 
351
- if (name === activeProject) {
352
- ctx.ui.notify(`Already on project: ${name}`, "info");
353
- return;
354
- }
355
-
356
- const previous = activeProject;
357
-
358
- // Remember the current session under the PREVIOUS project before switching
359
- const currentSessionFile = ctx.sessionManager.getSessionFile();
360
- if (previous && currentSessionFile) {
361
- rememberSessionFile(previous, currentSessionFile);
362
- }
363
-
364
- // Try to restore the target project's last session
365
- const targetSession = getMappedSessionFile(name);
366
-
367
- if (targetSession) {
368
- // Persist the switch in the OLD session before replacing it
369
- pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
370
-
371
- const result = await ctx.switchSession(targetSession);
372
- if (result.cancelled) {
373
- // User cancelled; roll back in-memory state
374
- activeProject = previous;
375
- ctx.ui.notify(`Switch cancelled. Staying on ${previous ?? "no project"}.`, "info");
576
+ if (ctx.mode === "tui") {
577
+ // Selection dialog; dismissing falls back to the plain list.
578
+ const choice = await ctx.ui.select("Switch project", projects);
579
+ if (choice !== undefined) {
580
+ await switchToProject(choice, ctx, false);
581
+ return;
582
+ }
583
+ notifyPlainProjectList(ctx, projects);
376
584
  return;
377
585
  }
378
586
 
379
- // switchSession fires a new session_start, which restores state from
380
- // the target session's entries (or auto-detects). Set it explicitly as
381
- // a safety net in case the session has no project entry yet.
382
- activeProject = name;
383
- pi.setSessionName(name);
384
- rememberSessionFile(name, targetSession);
385
-
386
- const path = projectPath(name);
387
- const branch = getGitBranch(path);
388
- const branchStr = branch ? ` on branch \`${branch}\`` : "";
389
- ctx.ui.notify(
390
- `Switched to ${name} — session restored: ${basename(targetSession)}\n` +
391
- `Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}`,
392
- "info"
393
- );
587
+ // rpc/json/print and any other surface: unchanged plain list
588
+ notifyPlainProjectList(ctx, projects);
394
589
  return;
395
590
  }
396
591
 
397
- // ── Fallback: no stored session (or file gone) -> same-session switch ─
398
- activeProject = name;
399
-
400
- // Persist to session
401
- pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
402
-
403
- // Update session name
404
- pi.setSessionName(name);
405
-
406
- const path = projectPath(name);
407
- const branch = getGitBranch(path);
408
- const branchStr = branch ? ` on branch \`${branch}\`` : "";
409
- const fromStr = previous ? ` (was: ${previous})` : "";
410
-
411
- // Current session identity — still valid on this path (no session replacement)
412
- const currentFile = currentSessionFile ?? ctx.sessionManager.getSessionFile();
413
- const sessionLine = currentFile
414
- ? `Continuing session: ${basename(currentFile)}`
415
- : "Continuing current session";
416
-
417
- ctx.ui.notify(
418
- `Switched to ${name}${fromStr} — first session in this project\n` +
419
- `Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}\n` +
420
- sessionLine,
421
- "info"
422
- );
423
-
424
- // Announce to the agent so it operates in the new context
425
- await ctx.waitForIdle();
426
- pi.sendUserMessage(
427
- `[Project switched to **${name}**]\n` +
428
- `Working directory: \`${path}\`${branchStr}\n` +
429
- `Please keep all file operations within this project from now on.`,
430
- { deliverAs: "followUp" }
431
- );
592
+ // ── Switch project ────────────────────────────────────────────────────
593
+ await switchToProject(name, ctx, createOptIn);
432
594
  },
433
595
  });
434
596
  }
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name": "pi-project-switcher", "version": "0.5.0", "description": "pi coding agent extension: switch between projects under a configurable base directory via /project", "main": "index.ts", "type": "module", "scripts": {"test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit"}, "keywords": ["pi", "pi-package", "pi-extension", "project", "switcher", "project-switching"], "author": "stefclawd", "license": "MIT", "repository": {"type": "git", "url": "git+https://github.com/stefclawd/pi-project-switcher.git"}, "bugs": {"url": "https://github.com/stefclawd/pi-project-switcher/issues"}, "homepage": "https://github.com/stefclawd/pi-project-switcher#readme", "files": ["index.ts", "README.md", "LICENSE"], "engines": {"node": ">=22.19.0"}, "pi": {"extensions": ["./index.ts"]}, "devDependencies": {"@earendil-works/pi-coding-agent": "^0.85.1", "@types/node": "^24.0.0", "typescript": "^5.7.0", "vitest": "^3.0.0"}}
1
+ {"name": "pi-project-switcher", "version": "0.6.0", "description": "pi coding agent extension: switch between projects under a configurable base directory via /project", "main": "index.ts", "type": "module", "scripts": {"test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit"}, "keywords": ["pi", "pi-package", "pi-extension", "project", "switcher", "project-switching"], "author": "stefclawd", "license": "MIT", "repository": {"type": "git", "url": "git+https://github.com/stefclawd/pi-project-switcher.git"}, "bugs": {"url": "https://github.com/stefclawd/pi-project-switcher/issues"}, "homepage": "https://github.com/stefclawd/pi-project-switcher#readme", "files": ["index.ts", "README.md", "LICENSE"], "engines": {"node": ">=22.19.0"}, "pi": {"extensions": ["./index.ts"]}, "devDependencies": {"@earendil-works/pi-coding-agent": "^0.85.1", "@types/node": "^24.0.0", "typescript": "^5.7.0", "vitest": "^3.0.0"}}