pi-ui-extension 1.1.11 → 1.2.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.
package/README.md CHANGED
@@ -34,9 +34,18 @@ The `SESSIONS` panel provides quick access to recent sessions:
34
34
  - Click a session to switch to it.
35
35
  - Rename a non-current session.
36
36
  - Delete a non-current session.
37
- - Use the `⊗` action to remove all other sessions.
37
+ - Use `⊗` to move other unpinned sessions to trash while keeping the current and pinned sessions.
38
38
  - Click `SESSIONS ↻` to refresh the session list.
39
39
 
40
+ **Advanced Sessions management (v1.2.0+)**: click `≡` on the Sessions header or run `/sessions-manager`.
41
+
42
+ - Browse all saved sessions for the current project in **8-item pages**, with previous/next navigation and refresh.
43
+ - Search session titles or IDs case-insensitively, and sort by latest update, oldest update, message count, or title.
44
+ - Switch, rename, pin/unpin, or move an individual session to trash. Deletions require confirmation and the active session cannot be deleted.
45
+ - Pinned sessions (`★`) appear first, including in the five-session quick sidebar. Pins persist as IDs (no session content) in `pi-ui-extension/session-pins.json` under Pi's agent directory.
46
+ - Each session row has a quick pin button: `☆` means unpinned and `★` means pinned; clicking it toggles pinning without opening the manager. The existing `✎` rename and `×` delete controls are unchanged. On narrow sidebars, the pin action hides to preserve the existing controls.
47
+ - The `⊗` bulk cleanup now **protects both the active session and all pinned sessions**. Explicit single-session deletion still requires confirmation.
48
+
40
49
  To avoid corrupting active agent state, switching, renaming, deleting, and bulk cleanup are only allowed while Pi is idle. The current session cannot be deleted.
41
50
 
42
51
  ### 3. Resource shortcuts
@@ -180,7 +189,7 @@ Example configuration:
180
189
  To pin a specific version:
181
190
 
182
191
  ```bash
183
- pi install npm:pi-ui-extension@1.1.11
192
+ pi install npm:pi-ui-extension@1.2.0
184
193
  ```
185
194
 
186
195
  or:
@@ -188,7 +197,7 @@ or:
188
197
  ```json
189
198
  {
190
199
  "packages": [
191
- "npm:pi-ui-extension@1.1.11"
200
+ "npm:pi-ui-extension@1.2.0"
192
201
  ]
193
202
  }
194
203
  ```
@@ -216,7 +225,7 @@ Configuration form:
216
225
  Pin to a Git tag:
217
226
 
218
227
  ```bash
219
- pi install git:github.com/1993yihuan/pi-ui-extension@v1.1.11
228
+ pi install git:github.com/1993yihuan/pi-ui-extension@v1.2.0
220
229
  ```
221
230
 
222
231
  ### Temporary local-source testing
@@ -390,12 +399,14 @@ Checkout
390
399
  → npm pack --dry-run
391
400
  → npm Trusted Publishing (OIDC)
392
401
  → npm publish
393
- → verify npm Registry visibility (with retries)
402
+ → verify npm Registry visibility (up to ~10 minutes of retries)
394
403
  → GitHub Release
395
404
  ```
396
405
 
397
406
  npm publishing uses GitHub Actions OIDC Trusted Publishing, so no long-lived `NPM_TOKEN` is required in the repository.
398
407
 
408
+ Because npm can accept a publication before the new version becomes searchable, the release workflow waits up to approximately 10 minutes for registry indexing. If that verification times out, check the package on npm before retrying: a successful publish cannot be repeated for the same version.
409
+
399
410
  ## Compatibility and design principles
400
411
 
401
412
  - Does not modify Pi Core.
package/README.zh-CN.md CHANGED
@@ -34,9 +34,19 @@ Sidebar 支持鼠标交互、滚轮滚动和终端尺寸变化。终端高度不
34
34
  - 点击会话切换到对应 Session。
35
35
  - 重命名非当前 Session。
36
36
  - 删除非当前 Session。
37
- - 使用右上角 `⊗` 清理除当前 Session 外的其他会话。
37
+ - 使用右上角 `⊗` 清理其他未置顶会话(保留当前和置顶 Session)。
38
38
  - 点击 `SESSIONS ↻` 刷新会话列表。
39
39
 
40
+ **Sessions 高级管理(v1.2.0 起)**:点击标题右侧的 `≡`,或输入 `/sessions-manager` 打开完整会话管理器。
41
+
42
+ - 全量浏览当前项目的历史 Session,**每页 8 条**,可向前 / 向后翻页和手动刷新。
43
+ - 按会话标题或 Session ID 搜索(不区分大小写),支持最近更新、最早更新、消息数量和标题排序。
44
+ - 单独查看会话操作:切换、重命名、置顶 / 取消置顶、移到回收站。删除前有确认,当前 Session 不允许删除。
45
+ - 置顶会话始终优先排序,并使用 `★` 标记,优先显示在 Sidebar 的最近 5 条会话中。
46
+ - Sidebar 每条会话右侧新增快捷置顶按钮:`☆` 表示未置顶,`★` 表示已置顶,点击立即切换;与 `✎` 重命名、`×` 删除互不影响。Sidebar 较窄时优先保留原有操作按钮。
47
+ - 置顶 ID 持久保存在 Pi 的 agent 目录下 `pi-ui-extension/session-pins.json`,不保存会话内容。
48
+ - 使用 `⊗` 批量清理时,**保留当前会话及所有置顶会话**;显式删除单条置顶会话仍需确认。
49
+
40
50
  为避免正在执行中的 Agent 状态被破坏,会话切换、重命名、删除和批量清理只会在 Pi 空闲时执行;当前正在使用的 Session 不允许删除。
41
51
 
42
52
  ### 3. 资源快速查看
@@ -180,7 +190,7 @@ Pi 会安装 package,并将声明写入个人配置:
180
190
  如果希望固定版本:
181
191
 
182
192
  ```bash
183
- pi install npm:pi-ui-extension@1.1.11
193
+ pi install npm:pi-ui-extension@1.2.0
184
194
  ```
185
195
 
186
196
  或者:
@@ -188,7 +198,7 @@ pi install npm:pi-ui-extension@1.1.11
188
198
  ```json
189
199
  {
190
200
  "packages": [
191
- "npm:pi-ui-extension@1.1.11"
201
+ "npm:pi-ui-extension@1.2.0"
192
202
  ]
193
203
  }
194
204
  ```
@@ -216,7 +226,7 @@ pi install git:github.com/1993yihuan/pi-ui-extension
216
226
  固定到 Git tag:
217
227
 
218
228
  ```bash
219
- pi install git:github.com/1993yihuan/pi-ui-extension@v1.1.11
229
+ pi install git:github.com/1993yihuan/pi-ui-extension@v1.2.0
220
230
  ```
221
231
 
222
232
  ### 临时加载本地源码
@@ -390,12 +400,14 @@ Checkout
390
400
  → npm pack --dry-run
391
401
  → npm Trusted Publishing (OIDC)
392
402
  → npm publish
393
- → 重试检查 npm Registry 已可查询新版本
403
+ → 重试检查 npm Registry 已可查询新版本(最多约 10 分钟)
394
404
  → GitHub Release
395
405
  ```
396
406
 
397
407
  npm 发布使用 GitHub Actions OIDC Trusted Publishing,不需要在 GitHub Repository 中保存长期 `NPM_TOKEN`。
398
408
 
409
+ npm 可能先接受发布请求,再经过一段时间才能查询到新版本。Release 工作流会等待最多约 10 分钟让 Registry 完成索引。如果检查超时,请先核对 npm 上的实际版本再决定如何处理,避免对已发布的同一个版本重复执行 `npm publish`。
410
+
399
411
  ## 兼容性与设计原则
400
412
 
401
413
  - 不修改 Pi Core。
@@ -8,6 +8,7 @@ import {
8
8
  SettingsManager,
9
9
  SessionManager,
10
10
  type ExtensionAPI,
11
+ type ExtensionContext,
11
12
  } from "@earendil-works/pi-coding-agent";
12
13
  import { installContextEstimates } from "./ctx/data.ts";
13
14
  import { createActivityTracker, type ActivityTracker } from "./ctx/activity.ts";
@@ -20,6 +21,7 @@ import {
20
21
  import type { ResourceButtonId } from "./session/panel.ts";
21
22
  import { installSidebar, type SidebarController } from "./sidebar/index.ts";
22
23
  import { deleteSessionFile } from "./session/mutations.ts";
24
+ import { openSessionManager, readSessionPins, sessionsEligibleForClear, sortedSessions, toggleSessionPin } from "./session/manager.ts";
23
25
  import type { SessionListItem } from "./session/panel.ts";
24
26
  import { installToolViewController } from "./tool-view/controller.ts";
25
27
  import type { ToolViewMode } from "./tool-view/types.ts";
@@ -32,6 +34,8 @@ const SESSION_SWITCH_COMMAND = "sidebar-session-switch";
32
34
  const SESSION_RENAME_COMMAND = "sidebar-session-rename";
33
35
  const SESSION_DELETE_COMMAND = "sidebar-session-delete";
34
36
  const SESSION_CLEAR_COMMAND = "sidebar-session-clear-others";
37
+ const SESSION_MANAGER_COMMAND = "sessions-manager";
38
+ const SESSION_PINS_FILE = join(getAgentDir(), "pi-ui-extension", "session-pins.json");
35
39
 
36
40
  export type ResourceEntry = { name: string; description: string; content?: string };
37
41
 
@@ -208,12 +212,8 @@ export async function loadResourceEntries(
208
212
  */
209
213
  async function clearOtherSessions(
210
214
  pi: ExtensionAPI,
211
- cwd: string,
212
- sessionDir: string,
213
- keepId: string,
215
+ targets: readonly { readonly path: string }[],
214
216
  ): Promise<{ readonly deleted: number; readonly failed: number; readonly firstError?: string }> {
215
- const sessions = await SessionManager.list(cwd, sessionDir);
216
- const targets = sessions.filter((session) => session.id !== keepId);
217
217
  let deleted = 0;
218
218
  let firstError: string | undefined;
219
219
  for (const target of targets) {
@@ -264,6 +264,65 @@ export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
264
264
  }
265
265
  };
266
266
  let switchRequested = false;
267
+ let sessionManagerOpen = false;
268
+ const safeReadPins = (): ReadonlySet<string> => {
269
+ try { return readSessionPins(SESSION_PINS_FILE); }
270
+ catch { return new Set(); } // Broken preferences must not hide saved sessions.
271
+ };
272
+ const loadSessionRows = async (ctx: ExtensionContext): Promise<readonly SessionListItem[]> => {
273
+ const currentId = ctx.sessionManager.getSessionId();
274
+ const pinned = safeReadPins();
275
+ const sessions = await SessionManager.list(ctx.cwd, ctx.sessionManager.getSessionDir());
276
+ return sortedSessions(sessions.map((session) => ({
277
+ id: session.id,
278
+ title: session.name ?? session.firstMessage,
279
+ modified: session.modified,
280
+ messageCount: session.messageCount,
281
+ current: session.id === currentId,
282
+ pinned: pinned.has(session.id),
283
+ })), "recent");
284
+ };
285
+ const canManageSessions = (ctx: ExtensionContext): boolean => {
286
+ if (ctx.isIdle()) return true;
287
+ ctx.ui.notify("Wait for the current run to finish before managing sessions", "warning");
288
+ return false;
289
+ };
290
+ const selectSession = (ctx: ExtensionContext, session: SessionListItem): void => {
291
+ if (session.current || switchRequested || !canManageSessions(ctx)) return;
292
+ switchRequested = true;
293
+ pi.sendUserMessage(`/${SESSION_SWITCH_COMMAND} ${session.id}`, { expandPromptTemplates: true });
294
+ };
295
+ const renameSession = (ctx: ExtensionContext, session: SessionListItem): void => {
296
+ if (!canManageSessions(ctx)) return;
297
+ pi.sendUserMessage(`/${SESSION_RENAME_COMMAND} ${session.id}`, { expandPromptTemplates: true });
298
+ };
299
+ const deleteSession = (ctx: ExtensionContext, session: SessionListItem): void => {
300
+ if (session.current) {
301
+ ctx.ui.notify("Cannot delete the currently active session", "warning");
302
+ return;
303
+ }
304
+ if (!canManageSessions(ctx)) return;
305
+ pi.sendUserMessage(`/${SESSION_DELETE_COMMAND} ${session.id}`, { expandPromptTemplates: true });
306
+ };
307
+ const launchSessionManager = async (ctx: ExtensionContext): Promise<void> => {
308
+ if (sessionManagerOpen || !canManageSessions(ctx)) return;
309
+ sessionManagerOpen = true;
310
+ try {
311
+ await openSessionManager({
312
+ ui: ctx.ui,
313
+ loadSessions: () => loadSessionRows(ctx),
314
+ togglePin: (id) => toggleSessionPin(SESSION_PINS_FILE, id),
315
+ onPinChanged: () => refreshSessions(),
316
+ onSwitch: (session) => selectSession(ctx, session),
317
+ onRename: (session) => renameSession(ctx, session),
318
+ onDelete: (session) => deleteSession(ctx, session),
319
+ });
320
+ } catch (error) {
321
+ ctx.ui.notify(`Failed to open Sessions manager: ${error instanceof Error ? error.message : String(error)}`, "error");
322
+ } finally {
323
+ sessionManagerOpen = false;
324
+ }
325
+ };
267
326
  const getMcpServerNames = () => mcpResources.map((server) => server.name);
268
327
  const updateMcpStatus = (): void => {
269
328
  mcpStatus = buildMcpStatusSnapshot(getAgentDir(), mcpCwd, mcpTrusted, registeredServers, liveMcpServers);
@@ -285,6 +344,11 @@ export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
285
344
  updateMcpStatus();
286
345
  });
287
346
 
347
+ pi.registerCommand(SESSION_MANAGER_COMMAND, {
348
+ description: "Search, sort, page, pin, switch, rename and delete saved sessions",
349
+ handler: async (_args, ctx) => launchSessionManager(ctx),
350
+ });
351
+
288
352
  pi.registerCommand(SESSION_RENAME_COMMAND, {
289
353
  description: "Rename a saved session selected from the sidebar",
290
354
  handler: async (args, ctx) => {
@@ -357,23 +421,24 @@ export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
357
421
  });
358
422
 
359
423
  pi.registerCommand(SESSION_CLEAR_COMMAND, {
360
- description: "Move every saved session except the active one to trash",
424
+ description: "Move unpinned saved sessions except the active one to trash",
361
425
  handler: async (_args, ctx) => {
362
426
  try {
363
427
  const currentSessionId = ctx.sessionManager.getSessionId();
428
+ const pinnedIds = readSessionPins(SESSION_PINS_FILE);
364
429
  const sessions = await SessionManager.list(ctx.cwd, ctx.sessionManager.getSessionDir());
365
- const others = sessions.filter((session) => session.id !== currentSessionId);
430
+ const others = sessionsEligibleForClear(sessions, currentSessionId, pinnedIds);
366
431
  if (others.length === 0) {
367
- ctx.ui.notify("No other sessions to clear", "info");
432
+ ctx.ui.notify("No unpinned sessions to clear", "info");
368
433
  return;
369
434
  }
370
435
  const confirmed = await ctx.ui.confirm(
371
436
  "Clear other sessions",
372
- `Move ${others.length} other session(s) to trash? The active session is kept.`,
437
+ `Move ${others.length} unpinned session(s) to trash? The active and pinned sessions are kept.`,
373
438
  );
374
439
  if (!confirmed) return;
375
440
 
376
- const result = await clearOtherSessions(pi, ctx.cwd, ctx.sessionManager.getSessionDir(), currentSessionId);
441
+ const result = await clearOtherSessions(pi, others);
377
442
  refreshSessions();
378
443
  if (result.failed > 0) {
379
444
  ctx.ui.notify(
@@ -541,44 +606,20 @@ export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
541
606
  onSelectToolViewMode: (mode): void => {
542
607
  void toolView.setMode(mode, ctx);
543
608
  },
544
- loadSessions: async (): Promise<readonly SessionListItem[]> => {
545
- const currentSessionId = ctx.sessionManager.getSessionId();
546
- const sessions = await SessionManager.list(ctx.cwd, ctx.sessionManager.getSessionDir());
547
- return sessions.map((session) => ({
548
- id: session.id,
549
- title: session.name ?? session.firstMessage,
550
- modified: session.modified,
551
- messageCount: session.messageCount,
552
- current: session.id === currentSessionId,
553
- }));
554
- },
555
- onSelectSession: (session): void => {
556
- if (session.current || switchRequested) return;
557
- if (!ctx.isIdle()) {
558
- ctx.ui.notify("Wait for the current run to finish before switching sessions", "warning");
559
- return;
560
- }
561
- switchRequested = true;
562
- pi.sendUserMessage(`/${SESSION_SWITCH_COMMAND} ${session.id}`, { expandPromptTemplates: true });
563
- },
564
- onRenameSession: (session): void => {
565
- if (!ctx.isIdle()) {
566
- ctx.ui.notify("Wait for the current run to finish before renaming sessions", "warning");
567
- return;
568
- }
569
- pi.sendUserMessage(`/${SESSION_RENAME_COMMAND} ${session.id}`, { expandPromptTemplates: true });
570
- },
571
- onDeleteSession: (session): void => {
572
- if (session.current) {
573
- ctx.ui.notify("Cannot delete the currently active session", "warning");
574
- return;
575
- }
576
- if (!ctx.isIdle()) {
577
- ctx.ui.notify("Wait for the current run to finish before deleting sessions", "warning");
578
- return;
609
+ loadSessions: () => loadSessionRows(ctx),
610
+ onSelectSession: (session): void => selectSession(ctx, session),
611
+ onRenameSession: (session): void => renameSession(ctx, session),
612
+ onDeleteSession: (session): void => deleteSession(ctx, session),
613
+ onTogglePinSession: (session): void => {
614
+ try {
615
+ const pinned = toggleSessionPin(SESSION_PINS_FILE, session.id);
616
+ refreshSessions();
617
+ ctx.ui.notify(pinned ? "会话已置顶" : "已取消置顶", "info");
618
+ } catch (error) {
619
+ ctx.ui.notify(`保存置顶失败:${error instanceof Error ? error.message : String(error)}`, "error");
579
620
  }
580
- pi.sendUserMessage(`/${SESSION_DELETE_COMMAND} ${session.id}`, { expandPromptTemplates: true });
581
621
  },
622
+ onOpenSessionManager: (): void => { void launchSessionManager(ctx); },
582
623
  onDeleteOthers: (): void => {
583
624
  if (!ctx.isIdle()) {
584
625
  ctx.ui.notify("Wait for the current run to finish before clearing sessions", "warning");
@@ -0,0 +1,141 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ getSessionPage, openSessionManager, readSessionPins, sessionsEligibleForClear, sortedSessions, toggleSessionPin,
8
+ type SessionManagerUI,
9
+ } from "./manager.ts";
10
+ import type { SessionListItem } from "./panel.ts";
11
+
12
+ function session(id: string, modified: number, title = `Session ${id}`, messages = 1): SessionListItem {
13
+ return { id, title, modified: new Date(modified), messageCount: messages, current: id === "id-0" };
14
+ }
15
+
16
+ test("sorting, searching and pagination scale to hundreds of sessions with pinned first", () => {
17
+ const many = Array.from({ length: 105 }, (_, index) => session(`id-${index}`, index * 1000, `Branch ${index}`, index));
18
+ many[0] = { ...many[0]!, pinned: true };
19
+ assert.equal(sortedSessions(many, "recent")[0]!.id, "id-0");
20
+ assert.deepEqual(getSessionPage(many, "", "recent", 0).items.map((s) => s.id).slice(0, 3),
21
+ ["id-0", "id-104", "id-103"]);
22
+ assert.equal(getSessionPage(many, "", "recent", 0).pages, 14);
23
+ assert.equal(getSessionPage(many, "", "recent", 100).page, 13);
24
+ assert.equal(getSessionPage(many, "", "recent", -10).page, 0);
25
+ assert.equal(getSessionPage(many, "BRANCH 1", "recent", 0).total, 16);
26
+ assert.equal(getSessionPage(many, "id-60", "recent", 0).items[0]!.id, "id-60");
27
+ assert.equal(getSessionPage(many, "not found", "title", 0).total, 0);
28
+ assert.equal(sortedSessions(many, "messages")[1]!.id, "id-104");
29
+ assert.equal(sortedSessions(many, "oldest")[1]!.id, "id-1");
30
+ assert.equal(sortedSessions(many, "title")[1]!.id, "id-1");
31
+ assert.deepEqual(sortedSessions([session("b", 0), session("a", 0)], "recent").map((s) => s.id), ["a", "b"]);
32
+ });
33
+
34
+ test("bulk clear excludes current and pinned sessions", () => {
35
+ const sessions = [session("current", 0), session("pinned", 1), session("ordinary", 2)];
36
+ assert.deepEqual(sessionsEligibleForClear(sessions, "current", new Set(["pinned"])).map((s) => s.id), ["ordinary"]);
37
+ });
38
+
39
+ test("pins persist atomically with private permissions and invalid contents are not overwritten", () => {
40
+ const root = mkdtempSync(join(tmpdir(), "pi-session-pin-"));
41
+ const path = join(root, "preferences", "pins.json");
42
+ try {
43
+ assert.deepEqual([...readSessionPins(path)], []);
44
+ assert.equal(toggleSessionPin(path, "some-id"), true);
45
+ assert.equal(toggleSessionPin(path, "next-id"), true);
46
+ assert.deepEqual([...readSessionPins(path)], ["some-id", "next-id"]);
47
+ assert.equal(statSync(path).mode & 0o777, 0o600);
48
+ assert.equal(toggleSessionPin(path, "some-id"), false);
49
+ assert.deepEqual([...readSessionPins(path)], ["next-id"]);
50
+ assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), ["next-id"]);
51
+ assert.deepEqual(readdirSync(join(root, "preferences")).filter((name) => name.endsWith(".tmp")), []);
52
+ assert.throws(() => toggleSessionPin(path, "../../oops"), /Invalid session identifier/);
53
+ writeFileSync(path, "{not-valid}");
54
+ assert.throws(() => toggleSessionPin(path, "good-id"));
55
+ assert.equal(readFileSync(path, "utf8"), "{not-valid}");
56
+ } finally {
57
+ rmSync(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ type Choice = (title: string, choices: string[]) => string | undefined;
62
+ function scriptedUI(steps: Choice[]) {
63
+ const viewed: Array<{ title: string; choices: string[] }> = [];
64
+ const notices: string[] = [];
65
+ const ui: SessionManagerUI = {
66
+ async select(title, choices) {
67
+ viewed.push({ title, choices });
68
+ const choose = steps.shift();
69
+ if (!choose) throw new Error(`Unexpected selector: ${title}`);
70
+ const choice = choose(title, choices);
71
+ if (choice !== undefined) assert.ok(choices.includes(choice), `missing option: ${choice}`);
72
+ return choice;
73
+ },
74
+ async input() { return ""; },
75
+ async confirm() { return true; },
76
+ notify(message) { notices.push(message); },
77
+ };
78
+ return { ui, viewed, notices };
79
+ }
80
+
81
+ test("manager can navigate pages, pin and unpin, and switch using the selected session", async () => {
82
+ const root = mkdtempSync(join(tmpdir(), "pi-session-modal-"));
83
+ const path = join(root, "pins.json");
84
+ let switched = "";
85
+ let changes = 0;
86
+ const all = Array.from({ length: 21 }, (_, index) => session(`id-${index}`, index * 1000));
87
+ const { ui, viewed, notices } = scriptedUI([
88
+ (_title, choices) => choices.find((item) => item === "下一页 →"),
89
+ (_title, choices) => choices.find((item) => item === "下一页 →"),
90
+ (_title, choices) => choices.find((item) => item.startsWith("1. ")),
91
+ (_title, choices) => choices.find((item) => item === "置顶会话"),
92
+ (_title, choices) => choices.find((item) => item.startsWith("1. ★")),
93
+ (_title, choices) => choices.find((item) => item === "取消置顶"),
94
+ (_title, choices) => choices.find((item) => item.startsWith("1. ")),
95
+ (_title, choices) => choices.find((item) => item === "切换到此会话"),
96
+ ]);
97
+ try {
98
+ await openSessionManager({
99
+ ui,
100
+ loadSessions: async () => all.map((item) => ({ ...item, pinned: readSessionPins(path).has(item.id) })),
101
+ togglePin: (id) => toggleSessionPin(path, id),
102
+ onPinChanged() { changes++; },
103
+ onSwitch(item) { switched = item.id; },
104
+ onRename() { throw new Error("unexpected rename"); },
105
+ onDelete() { throw new Error("unexpected delete"); },
106
+ });
107
+ assert.equal(changes, 2);
108
+ assert.ok(switched.length > 0);
109
+ assert.deepEqual([...readSessionPins(path)], []);
110
+ assert.match(viewed[2]!.title, /3\/3/);
111
+ assert.match(viewed[4]!.choices[0]!, /★/);
112
+ assert.deepEqual(notices, ["会话已置顶", "已取消置顶"]);
113
+ } finally {
114
+ rmSync(root, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ test("manager searches, sorts, and requires confirmation to delete noncurrent sessions", async () => {
119
+ const names = [session("a", 1, "alpha"), session("b", 2, "Beta"), session("c", 3, "gamma")];
120
+ const { ui, viewed } = scriptedUI([
121
+ (_title, choices) => choices.find((item) => item.startsWith("⌕ ")), // Search
122
+ (_title, choices) => choices.find((item) => item.startsWith("↕ ")), // Sort
123
+ (_title, choices) => choices.find((item) => item.includes("会话标题")),
124
+ (_title, choices) => choices.find((value) => value.startsWith("1. ")),
125
+ (_title, choices) => choices.find((value) => value === "移到回收站"),
126
+ ]);
127
+ let confirmed = false;
128
+ let deleted = "";
129
+ ui.input = async () => "BETA";
130
+ ui.confirm = async () => { confirmed = true; return true; };
131
+ await openSessionManager({
132
+ ui, loadSessions: async () => names,
133
+ togglePin: () => false, onPinChanged() {},
134
+ onSwitch() {}, onRename() {},
135
+ onDelete(item) { deleted = item.id; },
136
+ });
137
+ assert.equal(confirmed, true);
138
+ assert.equal(deleted, "b");
139
+ assert.match(viewed[3]!.title, /1\/3/);
140
+ assert.ok(!viewed[3]!.choices.some((value) => value.includes("gamma")));
141
+ });
@@ -0,0 +1,183 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import type { SessionListItem } from "./panel.ts";
5
+
6
+ export type SessionSort = "recent" | "oldest" | "messages" | "title";
7
+ export const SESSION_PAGE_SIZE = 8;
8
+
9
+ /** Store only opaque session ids; session contents and titles never leave Pi. */
10
+ export function readSessionPins(path: string): ReadonlySet<string> {
11
+ let text: string;
12
+ try {
13
+ text = readFileSync(path, "utf8");
14
+ } catch (error) {
15
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Set();
16
+ throw error;
17
+ }
18
+ if (text.length > 128_000) throw new Error("Session pins file is too large");
19
+ const parsed: unknown = JSON.parse(text);
20
+ if (!Array.isArray(parsed) || parsed.length > 2000 ||
21
+ !parsed.every((id) => typeof id === "string" && /^[a-zA-Z0-9._-]{1,128}$/.test(id))) {
22
+ throw new Error("Invalid session pins file");
23
+ }
24
+ return new Set(parsed as string[]);
25
+ }
26
+
27
+ /** Atomic, user-private persistence. Never write back a corrupt pins file. */
28
+ export function toggleSessionPin(path: string, id: string): boolean {
29
+ if (!/^[a-zA-Z0-9._-]{1,128}$/.test(id)) throw new Error("Invalid session identifier");
30
+ const pins = new Set(readSessionPins(path));
31
+ const pinned = !pins.has(id);
32
+ if (pinned) pins.add(id);
33
+ else pins.delete(id);
34
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
35
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
36
+ try {
37
+ writeFileSync(temporary, JSON.stringify([...pins]), { encoding: "utf8", mode: 0o600 });
38
+ renameSync(temporary, path);
39
+ } finally {
40
+ rmSync(temporary, { force: true });
41
+ }
42
+ return pinned;
43
+ }
44
+
45
+ export function sortedSessions(sessions: readonly SessionListItem[], sort: SessionSort): SessionListItem[] {
46
+ return [...sessions].sort((a, b) => {
47
+ if (Boolean(a.pinned) !== Boolean(b.pinned)) return a.pinned ? -1 : 1;
48
+ const diff = sort === "recent" ? b.modified.getTime() - a.modified.getTime()
49
+ : sort === "oldest" ? a.modified.getTime() - b.modified.getTime()
50
+ : sort === "messages" ? b.messageCount - a.messageCount
51
+ : a.title.localeCompare(b.title);
52
+ return (Number.isFinite(diff) ? diff : 0) || a.id.localeCompare(b.id);
53
+ });
54
+ }
55
+
56
+ /** Pinned and active sessions are protected from the bulk-clear operation. */
57
+ export function sessionsEligibleForClear<T extends { readonly id: string }>(
58
+ sessions: readonly T[], activeId: string, pins: ReadonlySet<string>,
59
+ ): T[] {
60
+ return sessions.filter((session) => session.id !== activeId && !pins.has(session.id));
61
+ }
62
+
63
+ export function getSessionPage(
64
+ sessions: readonly SessionListItem[], query: string, sort: SessionSort, requestedPage: number,
65
+ pageSize = SESSION_PAGE_SIZE,
66
+ ): { readonly items: readonly SessionListItem[]; readonly total: number; readonly page: number; readonly pages: number } {
67
+ const q = query.trim().toLocaleLowerCase();
68
+ const filtered = sortedSessions(sessions.filter((session) =>
69
+ !q || session.title.toLocaleLowerCase().includes(q) || session.id.toLocaleLowerCase().includes(q),
70
+ ), sort);
71
+ const pages = Math.max(1, Math.ceil(filtered.length / Math.max(1, pageSize)));
72
+ const page = Math.max(0, Math.min(Number.isFinite(requestedPage) ? Math.floor(requestedPage) : 0, pages - 1));
73
+ return {
74
+ items: filtered.slice(page * pageSize, (page + 1) * pageSize),
75
+ total: filtered.length, page, pages,
76
+ };
77
+ }
78
+
79
+ export type SessionManagerUI = {
80
+ select(title: string, choices: string[]): Promise<string | undefined>;
81
+ input(title: string, placeholder?: string): Promise<string | undefined>;
82
+ confirm(title: string, message: string): Promise<boolean>;
83
+ notify(message: string, level: "info" | "warning" | "error"): void;
84
+ };
85
+
86
+ export type SessionManagerOptions = {
87
+ readonly ui: SessionManagerUI;
88
+ readonly loadSessions: () => Promise<readonly SessionListItem[]>;
89
+ readonly togglePin: (id: string) => boolean;
90
+ readonly onPinChanged: () => void;
91
+ readonly onSwitch: (session: SessionListItem) => void;
92
+ readonly onRename: (session: SessionListItem) => void;
93
+ readonly onDelete: (session: SessionListItem) => void;
94
+ };
95
+
96
+ const SORT_CHOICES: ReadonlyArray<{ sort: SessionSort; label: string }> = [
97
+ { sort: "recent", label: "最近更新" },
98
+ { sort: "oldest", label: "最早更新" },
99
+ { sort: "messages", label: "消息数量" },
100
+ { sort: "title", label: "会话标题" },
101
+ ];
102
+
103
+ function safeLabel(title: string): string {
104
+ const cleaned = stripTerminalSequences(title).replace(/[\u0000-\u001f\u007f]/g, " ")
105
+ .replace(/\s+/g, " ").trim() || "(未命名会话)";
106
+ return truncateToWidth(cleaned, 65, "…");
107
+ }
108
+
109
+ /** Modal, bounded page of sessions; never executes destructive operations without confirmation. */
110
+ export async function openSessionManager(options: SessionManagerOptions): Promise<void> {
111
+ const { ui } = options;
112
+ let query = "";
113
+ let sort: SessionSort = "recent";
114
+ let page = 0;
115
+ let sessions = await options.loadSessions();
116
+ for (;;) {
117
+ const current = getSessionPage(sessions, query, sort, page);
118
+ page = current.page;
119
+ const labels = current.items.map((session, index) =>
120
+ `${index + 1}. ${session.pinned ? "★ " : ""}${session.current ? "● " : ""}${safeLabel(session.title)} · ${session.messageCount} 条消息`,
121
+ );
122
+ const actions = [
123
+ "⌕ 搜索 / 清除搜索",
124
+ `↕ 排序:${SORT_CHOICES.find((choice) => choice.sort === sort)!.label}`,
125
+ ...(page > 0 ? ["← 上一页"] : []),
126
+ ...(page + 1 < current.pages ? ["下一页 →"] : []),
127
+ "↻ 刷新列表",
128
+ "关闭",
129
+ ];
130
+ const choice = await ui.select(
131
+ `Sessions · ${current.total}/${sessions.length} · ${page + 1}/${current.pages}${query ? ` · 搜索:${safeLabel(query)}` : ""}`,
132
+ [...labels, ...actions],
133
+ );
134
+ if (!choice || choice === "关闭") return;
135
+ if (choice === actions[0]) {
136
+ const next = await ui.input("搜索会话标题或 ID(留空清除搜索)", query);
137
+ if (next !== undefined) { query = next.trim(); page = 0; }
138
+ } else if (choice === actions[1]) {
139
+ const names = SORT_CHOICES.map(({ label, sort: key }) => `${key === sort ? "● " : "○ "}${label}`);
140
+ const selection = await ui.select("会话排序(置顶始终优先)", names);
141
+ const index = names.indexOf(selection ?? "");
142
+ if (index >= 0) { sort = SORT_CHOICES[index]!.sort; page = 0; }
143
+ } else if (choice === "← 上一页") {
144
+ page--;
145
+ } else if (choice === "下一页 →") {
146
+ page++;
147
+ } else if (choice === "↻ 刷新列表") {
148
+ sessions = await options.loadSessions();
149
+ } else {
150
+ const index = labels.indexOf(choice);
151
+ const session = current.items[index];
152
+ if (index < 0 || !session) continue;
153
+ const menu = [
154
+ ...(session.current ? [] : ["切换到此会话"]),
155
+ "重命名",
156
+ session.pinned ? "取消置顶" : "置顶会话",
157
+ ...(session.current ? [] : ["移到回收站"]),
158
+ "返回列表",
159
+ ];
160
+ const action = await ui.select(`${safeLabel(session.title)} · 管理`, menu);
161
+ if (action === "置顶会话" || action === "取消置顶") {
162
+ try {
163
+ const pinned = options.togglePin(session.id);
164
+ ui.notify(pinned ? "会话已置顶" : "已取消置顶", "info");
165
+ options.onPinChanged();
166
+ sessions = await options.loadSessions();
167
+ page = 0;
168
+ } catch (error) {
169
+ ui.notify(`保存置顶失败:${error instanceof Error ? error.message : String(error)}`, "error");
170
+ }
171
+ } else if (action === "移到回收站") {
172
+ const confirmed = await ui.confirm("删除会话", `将“${safeLabel(session.title)}”移到回收站?`);
173
+ if (confirmed) { options.onDelete(session); return; }
174
+ } else if (action === "切换到此会话") {
175
+ options.onSwitch(session);
176
+ return;
177
+ } else if (action === "重命名") {
178
+ options.onRename(session);
179
+ return;
180
+ }
181
+ }
182
+ }
183
+ }
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import type { TuiInputListener } from "@earendil-works/pi-tui";
3
+ import { stripTerminalSequences, type TuiInputListener } from "@earendil-works/pi-tui";
4
4
  import { SessionPanel, type SessionListItem } from "./panel.ts";
5
5
 
6
6
  const theme = { fg: (_color: string, text: string) => text } as any;
@@ -236,3 +236,197 @@ test("hides ⊗ on narrow panels", async () => {
236
236
  assert.doesNotMatch(panel.render(16)[0]!, /⊗/);
237
237
  panel.dispose();
238
238
  });
239
+
240
+ test("opens advanced manager from ≡ without triggering refresh or bulk trash", async () => {
241
+ let listener: TuiInputListener | undefined;
242
+ let opened = 0;
243
+ let cleared = 0;
244
+ let loads = 0;
245
+ const tui = {
246
+ addInputListener(next: TuiInputListener) { listener = next; return () => {}; },
247
+ requestRender() {},
248
+ currentLayout: undefined as any,
249
+ } as any;
250
+ const panel = new SessionPanel(theme, {
251
+ tui,
252
+ getDesiredWidth: () => 40,
253
+ loadSessions: async () => { loads++; return [session("one", "One session")]; },
254
+ onSelect() {}, onRename() {}, onDelete() {},
255
+ onDeleteOthers() { cleared++; },
256
+ onOpenResources() {},
257
+ onOpenManager() { opened++; },
258
+ });
259
+ tui.currentLayout = {
260
+ root: { component: panel, rect: { x: 10, y: 5, width: 40, height: 5 },
261
+ clip: { x: 10, y: 5, width: 40, height: 5 }, children: [] },
262
+ };
263
+ panel.refresh();
264
+ await flushAsync();
265
+ const header = panel.render(40)[0]!;
266
+ const managerColumn = header.indexOf("≡");
267
+ assert.ok(managerColumn > 0, "≡ must be present in the session header");
268
+ const x = 10 + managerColumn + 1;
269
+ assert.deepEqual(listener?.(`\u001b[<0;${x};6M`), { consume: true });
270
+ assert.deepEqual(listener?.(`\u001b[<0;${x};6m`), { consume: true });
271
+ await flushAsync();
272
+ assert.equal(opened, 1);
273
+ assert.equal(cleared, 0);
274
+ assert.equal(loads, 1);
275
+ panel.dispose();
276
+ });
277
+
278
+ test("pinned session remains visible ahead of the five-item sidebar limit", async () => {
279
+ const tui = { addInputListener: () => () => {}, requestRender() {} } as any;
280
+ const panel = new SessionPanel(theme, {
281
+ tui, getDesiredWidth: () => 42,
282
+ loadSessions: async () => [
283
+ { ...session("starred", "Important"), pinned: true },
284
+ ...Array.from({ length: 15 }, (_, index) => session(`other-${index}`, `Recent ${index}`)),
285
+ ],
286
+ onSelect() {}, onRename() {}, onDelete() {},
287
+ onDeleteOthers() {}, onOpenResources() {},
288
+ });
289
+ panel.refresh();
290
+ await flushAsync();
291
+ const output = panel.render(42).join("\n");
292
+ assert.match(output, /★○ Important/);
293
+ assert.match(output, /Recent 3/);
294
+ assert.doesNotMatch(output, /Recent 4/);
295
+ panel.dispose();
296
+ });
297
+
298
+ test("session row ☆/★ toggles pin in place without invoking rename, delete or switch", async () => {
299
+ let listener: TuiInputListener | undefined;
300
+ const calls: string[] = [];
301
+ let pinned = false;
302
+ const tui = {
303
+ addInputListener(next: TuiInputListener) { listener = next; return () => {}; },
304
+ requestRender() {},
305
+ currentLayout: undefined as any,
306
+ } as any;
307
+ const panel = new SessionPanel(theme, {
308
+ tui, getDesiredWidth: () => 40,
309
+ loadSessions: async () => [{ ...session("one", "Important session"), pinned }],
310
+ onSelect() { calls.push("select"); },
311
+ onRename() { calls.push("rename"); },
312
+ onDelete() { calls.push("delete"); },
313
+ onTogglePin(item) {
314
+ assert.equal(item.id, "one");
315
+ calls.push("pin");
316
+ pinned = !pinned;
317
+ panel.refresh();
318
+ },
319
+ onDeleteOthers() {}, onOpenResources() {}, now: () => 0,
320
+ });
321
+ tui.currentLayout = {
322
+ root: {
323
+ component: panel, rect: { x: 10, y: 5, width: 40, height: 5 },
324
+ clip: { x: 10, y: 5, width: 40, height: 5 }, children: [],
325
+ },
326
+ };
327
+ const click = (text: string) => {
328
+ const row = panel.render(40)[1]!;
329
+ const column = row.lastIndexOf(text);
330
+ assert.ok(column > 0, `missing button ${text}: ${row}`);
331
+ const x = 10 + column + 1;
332
+ assert.deepEqual(listener?.(`\u001b[<0;${x};7M`), { consume: true });
333
+ assert.deepEqual(listener?.(`\u001b[<0;${x};7m`), { consume: true });
334
+ };
335
+ panel.refresh();
336
+ await flushAsync();
337
+ assert.match(panel.render(40)[1]!, /☆ ✎ ×/);
338
+ click("☆");
339
+ await flushAsync();
340
+ assert.equal(pinned, true);
341
+ assert.match(panel.render(40)[1]!, /★ ✎ ×/);
342
+ assert.match(panel.render(40)[1]!, /★○ Important/);
343
+ click("★");
344
+ await flushAsync();
345
+ assert.equal(pinned, false);
346
+ assert.match(panel.render(40)[1]!, /☆ ✎ ×/);
347
+ assert.deepEqual(calls, ["pin", "pin"]);
348
+
349
+ click("✎");
350
+ await flushAsync();
351
+ click("×");
352
+ await flushAsync();
353
+ assert.deepEqual(calls, ["pin", "pin", "rename", "delete"]);
354
+ panel.dispose();
355
+ });
356
+
357
+ test("session pin hit area is distinct from rename/delete and cancelled on off-target release", async () => {
358
+ let listener: TuiInputListener | undefined;
359
+ const calls: string[] = [];
360
+ const tui = {
361
+ addInputListener(next: TuiInputListener) { listener = next; return () => {}; },
362
+ requestRender() {},
363
+ currentLayout: undefined as any,
364
+ } as any;
365
+ const panel = new SessionPanel(theme, {
366
+ tui, getDesiredWidth: () => 40,
367
+ loadSessions: async () => [{ ...session("active", "Current session"), current: true }],
368
+ onSelect() { calls.push("select"); }, onRename() { calls.push("rename"); },
369
+ onDelete() { calls.push("delete"); }, onTogglePin() { calls.push("pin"); },
370
+ onDeleteOthers() {}, onOpenResources() {}, now: () => 0,
371
+ });
372
+ tui.currentLayout = {
373
+ root: {
374
+ component: panel, rect: { x: 10, y: 5, width: 40, height: 5 },
375
+ clip: { x: 10, y: 5, width: 40, height: 5 }, children: [],
376
+ },
377
+ };
378
+ panel.refresh();
379
+ await flushAsync();
380
+ const line = panel.render(40)[1]!;
381
+ assert.match(line, /☆ ✎ ·/);
382
+ const pinX = 10 + line.indexOf("☆") + 1;
383
+ const renameX = 10 + line.indexOf("✎") + 1;
384
+ const endX = 10 + line.lastIndexOf("·") + 1;
385
+ listener?.(`\u001b[<0;${pinX};7M`);
386
+ listener?.(`\u001b[<0;${renameX};7m`);
387
+ await flushAsync();
388
+ assert.deepEqual(calls, []);
389
+ listener?.(`\u001b[<0;${endX};7M`);
390
+ listener?.(`\u001b[<0;${endX};7m`);
391
+ await flushAsync();
392
+ assert.deepEqual(calls, [], "current session cannot be deleted");
393
+ listener?.(`\u001b[<0;${pinX};7M`);
394
+ listener?.(`\u001b[<0;${pinX};7m`);
395
+ await flushAsync();
396
+ assert.deepEqual(calls, ["pin"], "current session can still be pinned");
397
+ panel.dispose();
398
+ });
399
+
400
+ test("narrow sidebar keeps prior rename/delete actions without showing a truncated pin button", async () => {
401
+ let listener: TuiInputListener | undefined;
402
+ const actions: string[] = [];
403
+ const tui = {
404
+ addInputListener(next: TuiInputListener) { listener = next; return () => {}; },
405
+ requestRender() {},
406
+ currentLayout: undefined as any,
407
+ } as any;
408
+ const panel = new SessionPanel(theme, {
409
+ tui, getDesiredWidth: () => 21,
410
+ loadSessions: async () => [session("one", "Short")],
411
+ onSelect() { actions.push("select"); }, onRename() { actions.push("rename"); },
412
+ onDelete() { actions.push("delete"); }, onTogglePin() { actions.push("pin"); },
413
+ onDeleteOthers() {}, onOpenResources() {}, now: () => 0,
414
+ });
415
+ tui.currentLayout = {
416
+ root: {
417
+ component: panel, rect: { x: 3, y: 2, width: 21, height: 5 },
418
+ clip: { x: 3, y: 2, width: 21, height: 5 }, children: [],
419
+ },
420
+ };
421
+ panel.refresh();
422
+ await flushAsync();
423
+ const line = stripTerminalSequences(panel.render(21)[1]!);
424
+ assert.match(line, / ✎ ×/);
425
+ assert.doesNotMatch(line, /☆/);
426
+ const pencilX = 3 + line.indexOf("✎") + 1;
427
+ assert.deepEqual(listener?.(`\u001b[<0;${pencilX};4M`), { consume: true }, `press at ${pencilX}: ${line}`);
428
+ assert.deepEqual(listener?.(`\u001b[<0;${pencilX};4m`), { consume: true }, `release at ${pencilX}: ${line}`);
429
+ await flushAsync();
430
+ assert.deepEqual(actions, ["rename"]);
431
+ panel.dispose();
432
+ });
@@ -12,8 +12,11 @@ import { installPriorityInputListener, parseSgrMouseEvent } from "../sidebar/res
12
12
 
13
13
  const MAX_VISIBLE_SESSIONS = 5;
14
14
  const SESSION_ACTION_WIDTH = 4;
15
+ const SESSION_PIN_ACTION_WIDTH = 2;
16
+ const MIN_SUMMARY_WIDTH_WITH_PIN = 12;
15
17
  const SESSION_TITLE_CELLS = " SESSIONS ↻ ".length;
16
18
  const CLEAR_OTHERS_CELL = " ⊗ ";
19
+ const MANAGE_SESSIONS_CELL = " ≡ ";
17
20
 
18
21
  export type SessionListItem = {
19
22
  readonly id: string;
@@ -21,6 +24,7 @@ export type SessionListItem = {
21
24
  readonly modified: Date;
22
25
  readonly messageCount: number;
23
26
  readonly current: boolean;
27
+ readonly pinned?: boolean;
24
28
  };
25
29
 
26
30
  export type ResourceButtonId = "agents" | "skills" | "extensions" | "mcp";
@@ -32,7 +36,9 @@ export type SessionPanelOptions = {
32
36
  readonly onSelect: (session: SessionListItem) => void;
33
37
  readonly onRename: (session: SessionListItem) => void;
34
38
  readonly onDelete: (session: SessionListItem) => void;
39
+ readonly onTogglePin?: (session: SessionListItem) => void;
35
40
  readonly onDeleteOthers: () => void;
41
+ readonly onOpenManager?: () => void;
36
42
  readonly onOpenResources: (button: ResourceButtonId) => void;
37
43
  readonly now?: () => number;
38
44
  };
@@ -44,7 +50,7 @@ const RESOURCE_BUTTONS: ReadonlyArray<{ id: ResourceButtonId; label: string }> =
44
50
  { id: "mcp", label: "MCP" },
45
51
  ];
46
52
 
47
- type SessionRowAction = "select" | "rename" | "delete";
53
+ type SessionRowAction = "select" | "pin" | "rename" | "delete";
48
54
  type SessionActionTarget = {
49
55
  readonly session: SessionListItem;
50
56
  readonly action: SessionRowAction;
@@ -102,23 +108,32 @@ function clearOthersSpan(renderWidth: number): { readonly start: number; readonl
102
108
  return { start: glyphStart - 1, end: glyphStart + CLEAR_OTHERS_CELL.length - 2 };
103
109
  }
104
110
 
105
- /** Title row with a right-aligned ⊗ button that trashes every other session. */
106
- function renderSessionTitleRow(theme: Theme, title: string, renderWidth: number, clearPressed: boolean): string {
111
+ function manageSessionsSpan(renderWidth: number): { readonly start: number; readonly end: number } | undefined {
112
+ const clear = clearOthersSpan(renderWidth);
113
+ const innerWidth = Math.max(1, renderWidth - 2);
114
+ if (!clear || innerWidth < SESSION_TITLE_CELLS + CLEAR_OTHERS_CELL.length + MANAGE_SESSIONS_CELL.length + 4) return undefined;
115
+ return { start: clear.start - MANAGE_SESSIONS_CELL.length, end: clear.start - 1 };
116
+ }
117
+
118
+ /** Title row with a right-aligned manager (≡) and clear-others (⊗) buttons. */
119
+ function renderSessionTitleRow(theme: Theme, title: string, renderWidth: number, clearPressed: boolean, managePressed: boolean, showManager: boolean): string {
107
120
  const innerWidth = Math.max(1, renderWidth - 2);
108
121
  const span = clearOthersSpan(renderWidth);
109
- const reserved = span ? CLEAR_OTHERS_CELL.length + 1 : 0;
122
+ const manager = showManager ? manageSessionsSpan(renderWidth) : undefined;
123
+ const reserved = span ? CLEAR_OTHERS_CELL.length + 1 + (manager ? MANAGE_SESSIONS_CELL.length : 0) : 0;
110
124
  const borderWidth = Math.max(0, innerWidth - SESSION_TITLE_CELLS - reserved);
111
125
  const left = Math.floor(borderWidth / 2);
112
126
  const right = borderWidth - left;
113
127
  const border = theme.fg("accent", `╭${"─".repeat(left)} ${title} ${"─".repeat(right)}`);
114
128
  if (!span) return `${border}${theme.fg("accent", "╮")}`;
115
129
  return border +
130
+ (manager ? theme.fg(managePressed ? "warning" : "accent", MANAGE_SESSIONS_CELL) : "") +
116
131
  theme.fg(clearPressed ? "warning" : "accent", CLEAR_OTHERS_CELL) +
117
132
  theme.fg("accent", "─╮");
118
133
  }
119
134
 
120
135
  function formatSessionSummary(session: SessionListItem, width: number, now: number): string {
121
- const marker = session.current ? "● " : "○ ";
136
+ const marker = `${session.pinned ? "★" : ""}${session.current ? "● " : "○ "}`;
122
137
  const metadata = `${session.messageCount} · ${formatRelativeTime(session.modified, now)}`;
123
138
  const fixedWidth = visibleWidth(marker) + visibleWidth(metadata) + 1;
124
139
  if (fixedWidth >= width) {
@@ -130,23 +145,29 @@ function formatSessionSummary(session: SessionListItem, width: number, now: numb
130
145
  return `${marker}${padToWidth(title, titleWidth)} ${metadata}`;
131
146
  }
132
147
 
133
- function formatSessionRow(session: SessionListItem, contentWidth: number, now: number): string {
148
+ function showPinButton(contentWidth: number, enabled: boolean): boolean {
149
+ return enabled && contentWidth >= SESSION_ACTION_WIDTH + SESSION_PIN_ACTION_WIDTH + MIN_SUMMARY_WIDTH_WITH_PIN;
150
+ }
151
+
152
+ function formatSessionRow(session: SessionListItem, contentWidth: number, now: number, enablePin: boolean): string {
134
153
  if (contentWidth <= SESSION_ACTION_WIDTH) {
135
154
  return formatSessionSummary(session, contentWidth, now);
136
155
  }
137
- const summaryWidth = contentWidth - SESSION_ACTION_WIDTH;
156
+ const pinVisible = showPinButton(contentWidth, enablePin);
157
+ const summaryWidth = contentWidth - SESSION_ACTION_WIDTH - (pinVisible ? SESSION_PIN_ACTION_WIDTH : 0);
138
158
  const summary = padToWidth(formatSessionSummary(session, summaryWidth, now), summaryWidth);
139
- return `${summary} ✎ ${session.current ? "·" : "×"}`;
159
+ return `${summary}${pinVisible ? ` ${session.pinned ? "★" : "☆"}` : ""} ✎ ${session.current ? "·" : "×"}`;
140
160
  }
141
161
 
142
162
  function sameTarget(left: SessionActionTarget | undefined, right: SessionActionTarget | undefined): boolean {
143
163
  return Boolean(left && right && left.session.id === right.session.id && left.action === right.action);
144
164
  }
145
165
 
146
- /** Recent-session panel: click the title to refresh, a row to switch, ✎ to rename, × to delete, or ⊗ to trash every other session. */
166
+ /** Recent sessions: click to switch, ☆/★ to pin, ✎ to rename, × to delete, or ⊗ to bulk trash. */
147
167
  export class SessionPanel implements Component {
148
168
  private pressedResource: ResourceButtonId | undefined;
149
169
  private clearPressed = false;
170
+ private managePressed = false;
150
171
 
151
172
  private sessions: readonly SessionListItem[] = [];
152
173
  private loading = true;
@@ -202,9 +223,14 @@ export class SessionPanel implements Component {
202
223
  const contentWidth = Math.max(1, renderWidth - 4);
203
224
  const contentColumn = x - box.rect.x - 2;
204
225
  if (contentColumn < 0 || contentColumn >= contentWidth) return undefined;
205
- if (contentWidth <= SESSION_ACTION_WIDTH || contentColumn < contentWidth - SESSION_ACTION_WIDTH) {
226
+ const pinVisible = showPinButton(contentWidth, Boolean(this.options.onTogglePin));
227
+ if (contentWidth <= SESSION_ACTION_WIDTH ||
228
+ contentColumn < contentWidth - SESSION_ACTION_WIDTH - (pinVisible ? SESSION_PIN_ACTION_WIDTH : 0)) {
206
229
  return { session, action: "select" };
207
230
  }
231
+ if (pinVisible && contentColumn < contentWidth - SESSION_ACTION_WIDTH) {
232
+ return { session, action: "pin" };
233
+ }
208
234
  if (contentColumn < contentWidth - 2) return { session, action: "rename" };
209
235
  return session.current ? undefined : { session, action: "delete" };
210
236
  }
@@ -221,7 +247,8 @@ export class SessionPanel implements Component {
221
247
  contains(box.clip, x, y) &&
222
248
  contains(box.rect, x, y) &&
223
249
  y === box.rect.y &&
224
- !this.isClearOthersTarget(x, y),
250
+ !this.isClearOthersTarget(x, y) &&
251
+ !this.isManageSessionsTarget(x, y),
225
252
  );
226
253
  }
227
254
 
@@ -236,11 +263,23 @@ export class SessionPanel implements Component {
236
263
  return contentColumn >= span.start && contentColumn <= span.end;
237
264
  }
238
265
 
266
+ private isManageSessionsTarget(x: number, y: number): boolean {
267
+ if (!this.options.onOpenManager) return false;
268
+ const box = this.getLayoutBox();
269
+ if (!box || !contains(box.clip, x, y) || !contains(box.rect, x, y) || y !== box.rect.y) return false;
270
+ const span = manageSessionsSpan(Math.max(1, Math.min(box.rect.width, this.options.getDesiredWidth())));
271
+ const column = x - box.rect.x - 2;
272
+ return Boolean(span && column >= span.start && column <= span.end);
273
+ }
274
+
239
275
  private invoke(target: SessionActionTarget): void {
240
276
  switch (target.action) {
241
277
  case "select":
242
278
  if (!target.session.current) this.options.onSelect(target.session);
243
279
  return;
280
+ case "pin":
281
+ this.options.onTogglePin?.(target.session);
282
+ return;
244
283
  case "rename":
245
284
  this.options.onRename(target.session);
246
285
  return;
@@ -261,6 +300,14 @@ export class SessionPanel implements Component {
261
300
  const isLeft = (mouse.button & 3) === 0;
262
301
 
263
302
  if (mouse.release) {
303
+ if (this.managePressed) {
304
+ this.managePressed = false;
305
+ if (this.isManageSessionsTarget(x, y)) {
306
+ queueMicrotask(() => { if (!this.disposed) this.options.onOpenManager?.(); });
307
+ }
308
+ this.options.tui.requestRender();
309
+ return { consume: true };
310
+ }
264
311
  if (this.pressedResource) {
265
312
  const pressed = this.pressedResource;
266
313
  this.pressedResource = undefined;
@@ -309,6 +356,13 @@ export class SessionPanel implements Component {
309
356
  }
310
357
 
311
358
  if (mouse.motion) {
359
+ if (this.managePressed) {
360
+ if (!this.isManageSessionsTarget(x, y)) {
361
+ this.managePressed = false;
362
+ this.options.tui.requestRender();
363
+ }
364
+ return { consume: true };
365
+ }
312
366
  if (this.refreshPressed) {
313
367
  if (!this.isRefreshTarget(x, y)) {
314
368
  this.refreshPressed = false;
@@ -344,6 +398,12 @@ export class SessionPanel implements Component {
344
398
  if (isWheel) return { consume: true };
345
399
  if (!isLeft) return undefined;
346
400
 
401
+ if (this.isManageSessionsTarget(x, y)) {
402
+ this.managePressed = true;
403
+ this.options.tui.requestRender();
404
+ return { consume: true };
405
+ }
406
+
347
407
  if (this.isClearOthersTarget(x, y)) {
348
408
  this.clearPressed = true;
349
409
  this.options.tui.requestRender();
@@ -408,7 +468,7 @@ export class SessionPanel implements Component {
408
468
  const body = this.sessions.length > 0
409
469
  ? this.sessions.map((session) => panelContent(
410
470
  this.theme,
411
- formatSessionRow(session, contentWidth, now),
471
+ formatSessionRow(session, contentWidth, now, Boolean(this.options.onTogglePin)),
412
472
  this.pressedTarget?.session.id === session.id
413
473
  ? "warning"
414
474
  : session.current
@@ -439,7 +499,7 @@ export class SessionPanel implements Component {
439
499
 
440
500
  const divider = this.theme.fg("dim", `├${"─".repeat(Math.max(1, renderWidth - 2))}┤`);
441
501
  return [
442
- renderSessionTitleRow(this.theme, this.loading || this.loadInFlight ? "SESSIONS …" : "SESSIONS ↻", renderWidth, this.clearPressed),
502
+ renderSessionTitleRow(this.theme, this.loading || this.loadInFlight ? "SESSIONS …" : "SESSIONS ↻", renderWidth, this.clearPressed, this.managePressed, Boolean(this.options.onOpenManager)),
443
503
  ...body,
444
504
  divider,
445
505
  resourceRow,
@@ -454,6 +514,7 @@ export class SessionPanel implements Component {
454
514
  this.disposed = true;
455
515
  this.refreshPressed = false;
456
516
  this.clearPressed = false;
517
+ this.managePressed = false;
457
518
  this.pressedResource = undefined;
458
519
  this.pressedTarget = undefined;
459
520
  this.uninstallInput();
@@ -69,7 +69,9 @@ export type DefaultSidebarPanelOptions = {
69
69
  readonly onSelectSession: (session: SessionListItem) => void;
70
70
  readonly onRenameSession: (session: SessionListItem) => void;
71
71
  readonly onDeleteSession: (session: SessionListItem) => void;
72
+ readonly onTogglePinSession?: (session: SessionListItem) => void;
72
73
  readonly onDeleteOthers: () => void;
74
+ readonly onOpenSessionManager?: () => void;
73
75
  readonly onOpenResources: (button: ResourceButtonId) => void;
74
76
  };
75
77
 
@@ -94,7 +96,9 @@ export function createDefaultSidebarPanels(
94
96
  onSelect: options.onSelectSession,
95
97
  onRename: options.onRenameSession,
96
98
  onDelete: options.onDeleteSession,
99
+ onTogglePin: options.onTogglePinSession,
97
100
  onDeleteOthers: options.onDeleteOthers,
101
+ onOpenManager: options.onOpenSessionManager,
98
102
  onOpenResources: options.onOpenResources,
99
103
  });
100
104
  panel.refresh();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extension",
3
- "version": "1.1.11",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Pi terminal UI extension with session management, context inspection, Tool/MCP usage tracking, sidebar panels, and a compact footer",
6
6
  "keywords": [