pi-ui-extension 1.1.10 → 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 +35 -10
- package/README.zh-CN.md +36 -10
- package/extensions/ctx/README.md +3 -3
- package/extensions/ctx/data.test.ts +3 -3
- package/extensions/ctx/tools.test.ts +3 -3
- package/extensions/index.ts +87 -49
- package/extensions/mcp/integration.test.ts +1 -1
- 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.test.ts +6 -11
- package/extensions/sidebar/default-panels.ts +4 -2
- package/extensions/tool-view/codemode-compact.ts +135 -0
- package/extensions/tool-view/codemode.test.ts +209 -0
- package/extensions/tool-view/controller.test.ts +2 -1
- package/extensions/tool-view/controller.ts +48 -81
- package/extensions/tool-view/panel.test.ts +8 -36
- package/extensions/tool-view/panel.ts +1 -7
- package/extensions/tool-view/renderer.test.ts +149 -0
- package/extensions/tool-view/usage.test.ts +0 -37
- package/extensions/tool-view/usage.ts +4 -70
- package/package.json +1 -1
- package/extensions/fabric-detection.test.ts +0 -15
- package/extensions/fabric-detection.ts +0 -7
- package/extensions/tool-view/trust.test.ts +0 -41
|
@@ -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
|
+
});
|