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 +16 -5
- package/README.zh-CN.md +17 -5
- package/extensions/index.ts +87 -46
- package/extensions/session/manager.test.ts +141 -0
- package/extensions/session/manager.ts +183 -0
- package/extensions/session/panel.test.ts +195 -1
- package/extensions/session/panel.ts +74 -13
- package/extensions/sidebar/default-panels.ts +4 -0
- package/package.json +1 -1
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
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
- 使用右上角 `⊗`
|
|
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.
|
|
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.
|
|
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.
|
|
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。
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
430
|
+
const others = sessionsEligibleForClear(sessions, currentSessionId, pinnedIds);
|
|
366
431
|
if (others.length === 0) {
|
|
367
|
-
ctx.ui.notify("No
|
|
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}
|
|
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,
|
|
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:
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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