@oldsuns/pi-switch 0.3.2 → 0.3.5

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.
@@ -0,0 +1,120 @@
1
+ const indexes = new WeakMap();
2
+ export const VISIBLE_TREE_LANES = 4;
3
+
4
+ export function isBranchPoint(node) {
5
+ return node?.children.length > 1;
6
+ }
7
+
8
+ export function isBranchStart(node) {
9
+ return node?.parentId != null && node.siblings > 1;
10
+ }
11
+
12
+ export function canFoldBranch(node) {
13
+ return isBranchStart(node) && node.children.length > 0;
14
+ }
15
+
16
+ function summary(text) {
17
+ const line = text.split(/\r?\n/).find((line) => line.trim() && !line.trim().startsWith("```"));
18
+ return (line ?? text).trim().replace(/^#{1,6}\s+/, "").replace(/^[-*]\s+/, "").replace(/\s+/g, " ").slice(0, 220);
19
+ }
20
+
21
+ export function sessionTree(preview) {
22
+ if (indexes.has(preview)) return indexes.get(preview);
23
+ const nodes = new Map(preview.messages.map((message, index) => [message.id, {
24
+ id: message.id, message, index, parentId: message.tree.parentId, children: [],
25
+ depth: 0, lane: 0, position: 1, siblings: 1, descendants: 0,
26
+ summary: summary(message.text),
27
+ }]));
28
+ if (nodes.size !== preview.messages.length) throw new Error("Session tree contains duplicate message IDs.");
29
+ const roots = [];
30
+ for (const node of nodes.values()) {
31
+ if (node.parentId == null) roots.push(node.id);
32
+ else {
33
+ const parent = nodes.get(node.parentId);
34
+ if (!parent) throw new Error("Session tree references a missing parent: " + node.parentId);
35
+ parent.children.push(node.id);
36
+ }
37
+ }
38
+ const ordered = [];
39
+ const pending = roots.toReversed();
40
+ roots.forEach((id, index) => { nodes.get(id).position = index + 1; nodes.get(id).siblings = roots.length; });
41
+ while (pending.length) {
42
+ const node = nodes.get(pending.pop());
43
+ ordered.push(node);
44
+ node.children.forEach((id, index) => {
45
+ const child = nodes.get(id);
46
+ child.depth = node.depth + 1;
47
+ // Only forks add visual indentation; a continuous conversation stays aligned.
48
+ child.lane = node.lane + Number(isBranchPoint(node));
49
+ child.position = index + 1;
50
+ child.siblings = node.children.length;
51
+ });
52
+ for (let index = node.children.length - 1; index >= 0; index--) pending.push(node.children[index]);
53
+ }
54
+ if (ordered.length !== nodes.size) throw new Error("Session tree contains a parent cycle.");
55
+ for (const node of ordered.toReversed()) {
56
+ node.descendants = node.children.reduce((count, id) => count + 1 + nodes.get(id).descendants, 0);
57
+ }
58
+ const tree = { nodes, roots, ordered };
59
+ // Preview responses are immutable; a new response receives its own index.
60
+ indexes.set(preview, tree);
61
+ return tree;
62
+ }
63
+
64
+ export function visibleTreeNodes(preview, folded) {
65
+ if (!preview) return [];
66
+ const tree = sessionTree(preview);
67
+ const visible = [];
68
+ const pending = tree.roots.toReversed();
69
+ while (pending.length) {
70
+ const node = tree.nodes.get(pending.pop());
71
+ visible.push(node);
72
+ if (!canFoldBranch(node) || !folded.has(node.id)) {
73
+ for (let index = node.children.length - 1; index >= 0; index--) pending.push(node.children[index]);
74
+ }
75
+ }
76
+ return visible;
77
+ }
78
+
79
+ export function reconcilePreview(state, preview) {
80
+ const tree = sessionTree(preview);
81
+ const folded = new Set([...state.folded].filter((id) => canFoldBranch(tree.nodes.get(id))));
82
+ const visible = visibleTreeNodes(preview, folded);
83
+ const visibleIds = new Set(visible.map((node) => node.id));
84
+ const ancestry = new Map([...(state.preview?.messages ?? []), ...preview.messages].map((message) => [message.id, message.tree.parentId]));
85
+ function visibleAncestor(id) {
86
+ const visited = new Set();
87
+ while (id && !visibleIds.has(id) && !visited.has(id)) {
88
+ visited.add(id);
89
+ id = ancestry.get(id);
90
+ }
91
+ return visibleIds.has(id) ? id : null;
92
+ }
93
+ return {
94
+ preview, folded,
95
+ messageId: visibleAncestor(state.messageId) ?? visibleAncestor(preview.activeMessageId) ?? visible[0]?.id ?? null,
96
+ };
97
+ }
98
+
99
+ export function expandedMessagePath(preview, folded, id) {
100
+ const tree = sessionTree(preview);
101
+ if (!tree.nodes.has(id)) throw new Error("The message is not in this session tree.");
102
+ const next = new Set(folded);
103
+ let parentId = tree.nodes.get(id)?.parentId;
104
+ while (parentId != null) {
105
+ next.delete(parentId);
106
+ parentId = tree.nodes.get(parentId).parentId;
107
+ }
108
+ return next;
109
+ }
110
+
111
+ export function messagePath(preview, id) {
112
+ const tree = sessionTree(preview);
113
+ const path = [];
114
+ let node = tree.nodes.get(id);
115
+ while (node) {
116
+ if (node.parentId == null || isBranchStart(node)) path.push(node);
117
+ node = tree.nodes.get(node.parentId);
118
+ }
119
+ return path.reverse();
120
+ }
@@ -0,0 +1,164 @@
1
+ import { h, t, icon, iconButton, emptyState, listSearch, date } from "./ui.js";
2
+ import { pageHeader, contextHelp } from "./shell.js";
3
+ import { canFoldBranch, isBranchPoint, isBranchStart, messagePath, sessionTree, visibleTreeNodes, VISIBLE_TREE_LANES } from "./session-tree.js";
4
+
5
+ const MESSAGE_ROLES = {
6
+ user: { zh: "用户", en: "User", icon: "user", tone: "blue" },
7
+ assistant: { zh: "Pi", en: "Pi", icon: "terminal", tone: "mauve" },
8
+ branchSummary: { zh: "分支摘要", en: "Branch summary", icon: "branch", tone: "teal" },
9
+ compaction: { zh: "压缩摘要", en: "Compaction", icon: "box", tone: "peach" },
10
+ custom: { zh: "自定义消息", en: "Custom message", icon: "file", tone: "muted" },
11
+ };
12
+
13
+ function messageRole(message) {
14
+ const role = Object.hasOwn(MESSAGE_ROLES, message.role) ? MESSAGE_ROLES[message.role] : { zh: message.role, en: message.role, icon: "file", tone: "muted" };
15
+ return { ...role, name: t(role.zh, role.en) };
16
+ }
17
+
18
+ export function visibleSessions(state) {
19
+ const query = state.sessionQuery.toLowerCase();
20
+ return state.sessions.filter((session) => (!state.namedOnly || session.name)
21
+ && [session.title, session.cwd, session.searchText].some((value) => value?.toLowerCase().includes(query)));
22
+ }
23
+
24
+ function sessionList(state) {
25
+ const sessions = visibleSessions(state);
26
+ const groups = groupSessions(sessions);
27
+ const search = listSearch({ scope: "session", query: state.sessionQuery, open: state.sessionSearchOpen, label: t("搜索会话", "Search sessions"), placeholder: t("搜索会话、工作目录…", "Search sessions, workspaces…") });
28
+ return `<section class="panel session-sidebar" aria-busy="${state.sessionsLoading}"><div class="panel-header"><h2>${t("本地会话", "Local sessions")}<span class="count">${sessions.length}</span></h2><div class="inline-actions">${search.button}${iconButton("refresh-sessions", "refresh", t("重新扫描会话", "Rescan sessions"))}</div></div>
29
+ ${search.field}
30
+ <div class="provider-filters"><label class="checkbox-label"><input type="checkbox" data-action="named-only" ${state.namedOnly ? "checked" : ""}>${t("仅显示已命名会话", "Only named sessions")}</label></div>
31
+ <div class="session-list" data-session-scroll="list">
32
+ ${sessions.length ? Array.from(groups, ([cwd, entries]) => `<div class="session-group" title="${h(cwd)}">${icon("folder")}<span>${h(cwd || t("未知工作目录", "Unknown workspace"))}</span></div>
33
+ ${entries.map((session) => `<button class="session-option ${session.name ? "named" : ""}" data-action="select-session" data-session="${h(session.id)}" aria-current="${state.sessionId === session.id}">
34
+ <div class="item-title" title="${h(session.title)}">${h(session.title)}</div><div class="session-meta"><span>${icon("messages")}${session.messageCount}</span><span>${date(session.modifiedAt)}</span></div>
35
+ </button>`).join("")}`).join("") : emptyState(t("没有找到会话", "No sessions found"), state.sessionQuery || state.namedOnly ? t("调整搜索条件,再试一次。", "Try changing your filters.") : t("使用 Pi 开始对话后,会话会出现在这里。", "Your conversations will appear here after using Pi."), "messages")}
36
+ </div></section>`;
37
+ }
38
+
39
+ function groupSessions(sessions) {
40
+ const groups = new Map();
41
+ for (const session of sessions) {
42
+ if (!groups.has(session.cwd)) groups.set(session.cwd, []);
43
+ groups.get(session.cwd).push(session);
44
+ }
45
+ return groups;
46
+ }
47
+
48
+ function foldLabel(node, collapsed) {
49
+ return collapsed ? t("展开此分支的 " + node.descendants + " 条后续消息", "Expand " + node.descendants + " following messages in this branch")
50
+ : t("折叠此分支的 " + node.descendants + " 条后续消息", "Collapse " + node.descendants + " following messages in this branch");
51
+ }
52
+
53
+ function branchLabel(node) {
54
+ return isBranchStart(node) ? t("分支 " + node.position + "/" + node.siblings, "Branch " + node.position + "/" + node.siblings) : "";
55
+ }
56
+
57
+ function branchDescription(node, tree) {
58
+ const label = branchLabel(node);
59
+ if (!label) return "";
60
+ const parentNumber = tree.nodes.get(node.parentId).index + 1;
61
+ return label + t(" · 接续 #" + parentNumber, " · From #" + parentNumber);
62
+ }
63
+
64
+ function messageHeader(node, state, view) {
65
+ const { message } = node;
66
+ const role = messageRole(message);
67
+ const collapsed = state.folded.has(node.id);
68
+ const branch = branchDescription(node, sessionTree(state.preview));
69
+ const fold = view === "reading" && canFoldBranch(node)
70
+ ? iconButton("toggle-branch", collapsed ? "chevron" : "chevronDown", foldLabel(node, collapsed), `data-message="${h(node.id)}" data-view="reading" aria-expanded="${!collapsed}"`) : "";
71
+ return `<div class="message-header"><span class="avatar ${role.tone}">${icon(role.icon)}</span><strong>${h(role.name)}</strong><span class="message-number">#${node.index + 1}</span>${branch ? `<span class="badge">${h(branch)}</span>` : ""}${message.label ? `<span class="badge message-label" title="${h(message.label)}">${h(message.label)}</span>` : ""}${node.id === state.preview.activeMessageId ? `<span class="badge">${t("当前节点", "Current node")}</span>` : ""}<span class="message-actions">${fold}${iconButton("copy-message", "copy", t("复制消息", "Copy message"), `data-message="${h(node.id)}" data-view="${view}"`)}</span></div>`;
72
+ }
73
+
74
+ function treeIndent(node, next) {
75
+ const depth = Math.min(node.lane, VISIBLE_TREE_LANES);
76
+ if (!depth) return "";
77
+ const continuingDepth = next ? Math.min(next.lane - Number(isBranchStart(next)), VISIBLE_TREE_LANES) : 0;
78
+ const guides = Array.from({ length: depth }, (_, index) => {
79
+ const level = index + 1;
80
+ const start = isBranchStart(node) && level === node.lane;
81
+ const end = level > continuingDepth;
82
+ return `<span class="tree-indent-guide${start ? " starts-branch" : ""}${end ? " ends-branch" : ""}"></span>`;
83
+ });
84
+ return `<span class="tree-indent" aria-hidden="true">${guides.join("")}</span>`;
85
+ }
86
+
87
+ function treeRow(node, state, next) {
88
+ const role = messageRole(node.message);
89
+ const collapsed = state.folded.has(node.id);
90
+ const selected = node.id === state.messageId;
91
+ const canFold = canFoldBranch(node);
92
+ const branch = branchDescription(node, sessionTree(state.preview));
93
+ const description = [role.name, "#" + (node.index + 1), branch, node.summary].filter(Boolean).join(" · ");
94
+ const expander = canFold
95
+ ? `<button class="tree-expander" type="button" tabindex="-1" data-action="toggle-branch" data-message="${h(node.id)}" data-view="tree" aria-expanded="${!collapsed}" aria-label="${h(foldLabel(node, collapsed))}" title="${h(foldLabel(node, collapsed))}">${icon(collapsed ? "chevron" : "chevronDown")}</button>`
96
+ : '<span class="tree-expander-space" aria-hidden="true"></span>';
97
+ return `<div class="tree-item message-node" role="treeitem" aria-level="${node.depth + 1}" aria-posinset="${node.position}" aria-setsize="${node.siblings}" aria-selected="${selected}" ${canFold ? `aria-expanded="${!collapsed}"` : ""} tabindex="${selected ? "0" : "-1"}" data-action="select-message" data-message="${h(node.id)}" aria-label="${h(description)}">
98
+ ${treeIndent(node, next)}${expander}<div class="tree-row-content">
99
+ ${branch ? `<div class="tree-row-branch" title="${h(branch)}">${h(branch)}</div>` : ""}
100
+ <div class="tree-row-meta"><span class="tree-role ${role.tone}">${icon(role.icon)}${h(role.name)}</span><span class="tree-node-number">#${node.index + 1}</span>${isBranchPoint(node) ? `<span class="tree-fork-count">${node.children.length} ${t("分支", "branches")}</span>` : ""}${node.id === state.preview.activeMessageId ? `<span class="tree-current">${t("当前", "Current")}</span>` : ""}</div>
101
+ <div class="tree-text" title="${h(node.summary)}">${h(node.summary || t("无文本内容", "No text content"))}</div>
102
+ <div class="tree-row-tags">${collapsed ? `<span class="tree-fold-count">+${node.descendants} ${t("条已折叠", "collapsed")}</span>` : ""}${node.lane > VISIBLE_TREE_LANES ? `<span>${t("分支层级 ", "Branch depth ")}${node.lane}</span>` : ""}${node.message.label ? `<span class="tree-message-label" title="${h(node.message.label)}">${h(node.message.label)}</span>` : ""}</div>
103
+ </div>
104
+ </div>`;
105
+ }
106
+
107
+ export function messageReader(state) {
108
+ const node = sessionTree(state.preview).nodes.get(state.messageId);
109
+ if (!node) return `<section id="session-message-reader" class="message-reader">${emptyState(t("选择一条消息", "Select a message"), t("点击树中的消息,在这里阅读内容。", "Select a tree node to read its content here."), "messages")}</section>`;
110
+ const path = messagePath(state.preview, node.id);
111
+ const crumbs = path.slice(-4).map((entry) => `<button class="reader-crumb" data-action="select-message" data-message="${h(entry.id)}" title="${h(entry.summary)}">${h(branchLabel(entry) || t("起点", "Start"))}</button>`).join('<span aria-hidden="true">/</span>');
112
+ const scrollKey = JSON.stringify([state.preview.id, node.id]);
113
+ return `<section id="session-message-reader" class="message-reader" data-message-context="${h(node.id)}" aria-label="${t("消息内容", "Message content")}"><div class="reader-heading"><div class="reader-breadcrumb" aria-label="${t("分支路径", "Branch path")}">${path.length > 4 ? '<span aria-hidden="true">… /</span>' : ""}${crumbs}</div>${messageHeader(node, state, "reader")}</div><div class="reader-body" data-session-scroll="message" data-scroll-key="${h(scrollKey)}"><div class="markdown">${node.message.html}</div></div></section>`;
114
+ }
115
+
116
+ function previewNotice(state) {
117
+ if (state.previewError) return `<div class="panel-body preview-notice"><div class="banner error" role="alert">${icon("warning")}<div>${h(state.previewError)}${state.preview ? `<p>${t("仍显示上次成功读取的内容。", "Showing the last successfully loaded content.")}</p>` : ""}</div></div><button class="btn" data-action="reload-preview">${icon("refresh")}${t("重试", "Retry")}</button></div>`;
118
+ if (state.previewLoading) return `<div class="${state.preview ? "preview-notice" : "loading-state"}" role="status"><span class="spinner"></span>${state.preview ? t("正在刷新会话…", "Refreshing conversation…") : t("正在读取会话…", "Loading conversation…")}</div>`;
119
+ return "";
120
+ }
121
+
122
+ function previewContent(state) {
123
+ const notice = previewNotice(state);
124
+ const preview = state.preview;
125
+ if (!preview) return notice;
126
+ if (!preview.messages.length) return notice + emptyState(t("没有可显示的消息", "No messages to display"), t("该会话没有符合当前筛选条件的文本消息。", "This session has no text messages matching the current filter."), "messages");
127
+ const tree = sessionTree(preview);
128
+ const nodes = visibleTreeNodes(preview, state.folded);
129
+ if (state.previewMode === "reading") {
130
+ return notice + `<div class="reading-view" data-session-scroll="reading" data-session-id="${h(preview.id)}" aria-label="${t("会话阅读", "Conversation reading")}">` + nodes.map((node) => `<article class="reading-message message-node ${node.message.tree.activePath ? "on-active-path" : ""}" data-depth="${Math.min(node.lane, VISIBLE_TREE_LANES)}" tabindex="${node.id === state.messageId ? "0" : "-1"}" aria-current="${node.id === state.messageId}" aria-label="${h(messageRole(node.message).name + ": " + node.summary)}" data-action="select-message" data-message="${h(node.id)}">${messageHeader(node, state, "reading")}<div class="markdown">${node.message.html}</div>${state.folded.has(node.id) ? `<div class="reading-fold-note">${icon("branch")}${t("已折叠 ", "Collapsed ")}${node.descendants} ${t("条后续消息", "following messages")}</div>` : ""}</article>`).join("") + "</div>";
131
+ }
132
+ return notice + `<div class="tree-workspace"><section class="tree-panel" aria-label="${t("消息树", "Message tree")}"><div class="tree-panel-heading"><strong>${t("消息树", "Message tree")}</strong><span>${nodes.length} / ${tree.ordered.length}</span></div><div class="tree-list" data-session-scroll="tree" data-session-id="${h(preview.id)}" role="tree" aria-label="${t("会话分支", "Conversation tree")}">${nodes.map((node, index) => treeRow(node, state, nodes[index + 1])).join("")}</div></section>${messageReader(state)}</div>`;
133
+ }
134
+
135
+ function sessionPreview(state) {
136
+ const session = state.sessions.find((entry) => entry.id === state.sessionId);
137
+ if (state.sessionId && !session) {
138
+ if (state.sessionsLoading) return `<section class="panel"><div class="loading-state" role="status"><span class="spinner"></span>${t("正在查找会话…", "Looking for the session…")}</div></section>`;
139
+ return `<section class="panel"><div class="panel-body"><div class="banner error" role="alert">${icon("warning")}<span>${state.sessionsError ? t("会话列表读取失败,请重新扫描。", "Could not load the session list. Rescan to try again.") : t("会话不存在或已删除。", "This session does not exist or has been deleted.")}</span></div><p class="missing-session-id">${h(state.sessionId)}</p><div class="inline-actions"><button class="btn" data-action="refresh-sessions">${icon("refresh")}${t("重新扫描", "Rescan sessions")}</button><button class="btn" data-action="clear-session">${t("返回列表", "Back to list")}</button></div></div></section>`;
140
+ }
141
+ if (!session) return `<section class="panel">${emptyState(t("回到对话发生的地方", "Pick up the thread"), t("选择左侧会话,浏览完整消息和分支历史。", "Select a session to explore its messages and branches."), "messages")}</section>`;
142
+ const tree = state.preview && sessionTree(state.preview);
143
+ const canFold = tree?.ordered.some(canFoldBranch);
144
+ return `<section class="panel session-preview" aria-busy="${state.previewLoading}"><div class="panel-header"><div><h2>${h(session.title)}</h2><div class="item-subtitle" title="${h(session.cwd)}">${h(session.cwd)}</div></div>${iconButton("delete-session", "trash", t("删除会话", "Delete session"), "", "danger")}</div>
145
+ <div class="preview-toolbar"><div class="segments" aria-label="${t("预览模式", "Preview mode")}"><button data-action="preview-mode" data-mode="tree" aria-pressed="${state.previewMode === "tree"}">${icon("branch")}${t("树状", "Tree")}</button><button data-action="preview-mode" data-mode="reading" aria-pressed="${state.previewMode === "reading"}">${icon("book")}${t("阅读", "Read")}</button></div>
146
+ <div class="preview-controls"><div class="preview-tree-actions" role="group" aria-label="${t("分支操作", "Branch actions")}">
147
+ ${iconButton("collapse-tree", "chevronsUp", t("全部折叠", "Collapse all"), canFold ? "" : "disabled")}
148
+ ${iconButton("expand-tree", "chevronsDown", t("全部展开", "Expand all"), state.folded.size ? "" : "disabled")}
149
+ ${iconButton("active-message", "target", t("定位当前", "Locate current"), state.preview?.activeMessageId ? "" : "disabled")}
150
+ </div><label class="checkbox-label"><input type="checkbox" data-action="user-only" ${state.userOnly ? "checked" : ""}>${t("仅用户", "User only")}</label></div>
151
+ </div>
152
+ ${previewContent(state)}
153
+ <div class="models-foot"><span>${session.messageCount} ${t("条消息", "messages")}${state.preview ? ' <span class="subtle-divider">·</span> ' + state.preview.branchPoints + " " + t("个分叉", "branches") : ""}</span><span>${date(session.modifiedAt)}</span></div>
154
+ </section>`;
155
+ }
156
+
157
+ export function sessions(state) {
158
+ const header = pageHeader(t("会话记录", "Session history"), "", `<button class="btn" data-action="refresh-sessions">${state.sessionsLoading ? '<span class="spinner"></span>' : icon("refresh")}${state.sessionsLoading ? t("正在扫描…", "Scanning…") : t("重新扫描", "Rescan sessions")}</button>`);
159
+ const error = state.sessionsError ? `<div class="banner error" role="alert">${icon("warning")}<span>${h(state.sessionsError)}</span></div>` : "";
160
+ if (!state.sessionsLoaded) return header + `<div class="loading-state" role="status"><span class="spinner"></span>${t("正在扫描会话…", "Scanning sessions…")}</div>`;
161
+ if (state.sessionsError && !state.sessions.length) return header + error;
162
+ return header + error + `<div class="sessions-layout">${sessionList(state)}${sessionPreview(state)}</div>`
163
+ + contextHelp([["↑ ↓", t("浏览消息", "Browse messages")], ["← →", t("折叠 / 展开与父子导航", "Fold / expand and navigate")], ["Space", t("折叠 / 展开分支", "Toggle branches")], ["Home / End", t("首条 / 末条", "First / last")], ["v", t("切换阅读视图", "Toggle reading")], ["Ctrl C", t("复制当前消息", "Copy message")], ["/", t("筛选会话", "Filter sessions")]]);
164
+ }
@@ -0,0 +1,48 @@
1
+ import { h, t, icon, toggle, iconButton } from "./ui.js";
2
+ import { pageHeader } from "./shell.js";
3
+ import { getTheme } from "./appearance.js";
4
+
5
+ function setting(title, description, control) {
6
+ return `<div class="setting-row"><div><h3>${title}</h3><p>${description}</p></div>${control}</div>`;
7
+ }
8
+
9
+ export function settings(state) {
10
+ const snapshot = state.snapshot;
11
+ const paths = [
12
+ [t("Provider 本地库", "Provider library"), "providers"],
13
+ ["Pi models.json", "piModels"],
14
+ ["Pi settings.json", "piSettings"],
15
+ [t("pi-switch 设置", "pi-switch settings"), "appSettings"],
16
+ [t("会话目录", "Session directory"), "sessions"],
17
+ [t("备份目录", "Backup directory"), "backups"],
18
+ ];
19
+ return pageHeader(t("设置", "Settings"), t("管理界面偏好、配置备份与程序更新。", "Manage interface preferences, configuration backups, and updates."))
20
+ + `<div class="settings-layout"><div>
21
+ <section class="panel settings-group"><div class="panel-header"><h2>${icon("sliders")}${t("偏好设置", "Preferences")}</h2></div>
22
+ ${setting(t("界面语言", "Language"), t("Web 和 TUI 共享这项设置。", "Shared between the Web interface and TUI."), `<select data-action="language" aria-label="${t("界面语言", "Interface language")}"><option value="zh-CN" ${snapshot.language === "zh-CN" ? "selected" : ""}>简体中文</option><option value="en" ${snapshot.language === "en" ? "selected" : ""}>English</option></select>`)}
23
+ ${setting(t("获取模型元数据", "Fetch model metadata"), t("导入时从 models.dev 获取上下文、价格与模型能力信息。", "Get context limits, pricing, and capabilities from models.dev during import."), toggle("metadata", snapshot.fetchModelMetadata, t("获取模型元数据", "Fetch model metadata")))}
24
+ ${!snapshot.fetchModelMetadata ? setting(t("默认模型参数", "Default model parameters"), t("未使用在线元数据时,导入模型采用这些缺省值。", "Defaults used when importing without online metadata."), `<button class="btn" data-action="model-defaults">${icon("edit")}${t("编辑", "Edit")}</button>`) : ""}
25
+ ${setting(t("API 密钥保存位置", "API key storage"), t("编辑 Provider 时,密钥写入 Pi 的 auth.json(推荐)还是 models.json。", "When saving a provider, its API key goes to Pi's auth.json (recommended) or models.json."), `<select data-action="key-storage" aria-label="${t("API 密钥保存位置", "API key storage")}"><option value="auth.json" ${snapshot.keyStorage !== "models.json" ? "selected" : ""}>auth.json</option><option value="models.json" ${snapshot.keyStorage === "models.json" ? "selected" : ""}>models.json</option></select>`)}
26
+ ${setting(t("TUI 启动时检查更新", "Check for updates on TUI startup"), t("控制终端界面的自动检查。Web 可在下方手动检查。", "Controls automatic checks in the terminal interface. Check manually below on the Web."), toggle("auto-updates", snapshot.checkUpdates, t("TUI 启动时检查更新", "Check for updates on TUI startup")))}
27
+ </section>
28
+ <section class="panel settings-group"><div class="panel-header"><h2>${icon("shield")}${t("配置维护", "Configuration tools")}</h2></div>
29
+ ${setting(t("重新读取配置", "Reload configuration"), t("读取磁盘上的最新内容,包括在 TUI 或 Pi 中的修改。", "Read the latest changes made in Pi, the TUI, or on disk."), `<button class="btn" data-action="reload">${icon("refresh")}${t("重载", "Reload")}</button>`)}
30
+ ${setting(t("验证配置", "Validate configuration"), t("检查配置文件、默认模型及写入锁状态。", "Check configuration files, the default model, and write locks."), `<button class="btn" data-action="doctor">${icon("shield")}${t("检查", "Validate")}</button>`)}
31
+ ${setting(t("浏览备份", "Browse backups"), t("每次写入前自动备份,最多保留最近 10 份。", "Automatically saved before writes. The last 10 backups are retained."), `<button class="btn" data-action="backups">${icon("history")}${t("浏览", "Browse")}</button>`)}
32
+ ${setting(t("从 OpenCode 导入", "Import from OpenCode"), t("选择已有 provider 导入本地库并同步到 Pi。", "Import selected providers into your library and sync them to Pi."), `<button class="btn" data-action="opencode">${icon("download")}${t("导入", "Import")}</button>`)}
33
+ </section>
34
+ <section class="panel settings-group"><div class="panel-header"><h2>${icon("terminal")}${t("关于 pi-switch", "About pi-switch")}</h2><span class="version">v${h(snapshot.version)}</span></div>
35
+ ${setting("pi-switch", t("为 Pi 打造的本地 provider 与模型管理工具。", "A local provider and model manager for Pi."), `<button class="btn" data-action="check-updates">${icon("refresh")}${t("检查更新", "Check updates")}</button>`)}
36
+ </section>
37
+ </div><div>
38
+ <section class="panel"><div class="panel-header"><h2>${icon("eye")}${t("外观", "Appearance")}</h2></div><div class="appearance-settings">
39
+ <fieldset class="theme-control"><legend class="sr-only">${t("界面主题", "Interface theme")}</legend>
40
+ <label class="theme-choice ${getTheme() === "dark" ? "selected" : ""}"><input type="radio" name="web-theme" value="dark" data-action="theme" ${getTheme() === "dark" ? "checked" : ""}>${icon("moon")}<span><strong>${t("暗色", "Dark")}</strong><small>Catppuccin Mocha</small></span></label>
41
+ <label class="theme-choice ${getTheme() === "light" ? "selected" : ""}"><input type="radio" name="web-theme" value="light" data-action="theme" ${getTheme() === "light" ? "checked" : ""}>${icon("sun")}<span><strong>${t("亮色", "Light")}</strong><small>Catppuccin Latte</small></span></label>
42
+ </fieldset>
43
+ </div></section>
44
+ <section class="panel section-gap"><div class="panel-header"><h2>${icon("folder")}${t("配置路径", "Configuration paths")}</h2></div><div class="path-list">
45
+ ${paths.map(([label, key]) => `<div class="path-row"><label>${label}</label><div class="path-value"><code>${h(snapshot.paths[key])}</code>${iconButton("copy-path", "copy", t("复制路径", "Copy path") + " · " + label, 'data-path="' + h(snapshot.paths[key]) + '"')}</div></div>`).join("")}
46
+ </div></section>
47
+ </div></div>`;
48
+ }
@@ -0,0 +1,48 @@
1
+ import { h, icon, iconButton, t } from "./ui.js";
2
+ import { getTheme, themeName } from "./appearance.js";
3
+
4
+ export const pages = {
5
+ overview: { zh: "主页", en: "Overview", icon: "home", index: "01" },
6
+ profiles: { zh: "配置", en: "Profiles", icon: "sliders", index: "02" },
7
+ sessions: { zh: "会话", en: "Sessions", icon: "messages", index: "03" },
8
+ settings: { zh: "设置", en: "Settings", icon: "settings", index: "04" },
9
+ };
10
+
11
+ export function shell(state, content) {
12
+ const page = pages[state.page];
13
+ return `<div class="app-shell">
14
+ <button type="button" class="nav-scrim ${state.navOpen ? "visible" : ""}" data-action="close-nav" aria-label="${t("关闭导航", "Close navigation")}" tabindex="-1"></button>
15
+ <aside id="sidebar" class="sidebar ${state.navOpen ? "open" : ""}" aria-label="${t("主导航", "Main navigation")}" ${matchMedia("(max-width: 760px)").matches && !state.navOpen ? "inert" : ""}>
16
+ <a class="brand" href="#overview" aria-label="pi-switch · ${t("主页", "Overview")}">
17
+ <span class="brand-mark" aria-hidden="true">π</span><span class="brand-name">pi-switch</span>
18
+ </a>
19
+ <div class="nav-label">WORKSPACE</div>
20
+ <nav class="nav-links">
21
+ ${Object.entries(pages).map(([key, item]) => `<a class="nav-link ${state.page === key ? "active" : ""}" href="#${key}" ${state.page === key ? 'aria-current="page"' : ""}>
22
+ ${icon(item.icon)}<span>${t(item.zh, item.en)}</span><span class="nav-index">${item.index}</span>
23
+ </a>`).join("")}
24
+ </nav>
25
+ <div class="sidebar-bottom">
26
+ <div class="local-card"><div class="local-card-heading">${icon("terminal")}Pi Agent<span class="status-dot"></span></div><code title="${h(state.snapshot.paths.piModels)}">${h(state.snapshot.paths.piModels.replace(/[/\\]models\.json$/, ""))}</code></div>
27
+ <div class="sidebar-footer"><span class="version">v${h(state.snapshot.version)}</span><a class="icon-button" href="https://github.com/OldSuns/pi-switch" target="_blank" rel="noreferrer" aria-label="GitHub" title="GitHub">${icon("external")}</a></div>
28
+ </div>
29
+ </aside>
30
+ <header class="topbar">
31
+ <div class="breadcrumb">${iconButton("toggle-nav", "menu", t("打开导航", "Open navigation"), 'aria-controls="sidebar" aria-expanded="' + state.navOpen + '"', "mobile-menu")}${icon("terminal")}<span>workspace</span><span class="separator">/</span><strong>${page.en.toLowerCase()}</strong></div>
32
+ <div class="topbar-actions"><span class="connection"><span class="status-dot ${state.connected ? "" : "off"}"></span>${state.connected ? t("本地服务已连接", "Connected locally") : t("本地连接已断开", "Disconnected")}</span><span class="topbar-divider"></span>${iconButton("reload", "refresh", t("重新读取配置 · R", "Reload configuration · R"))}${iconButton("help", "help", t("快捷键 · ?", "Keyboard shortcuts · ?"))}</div>
33
+ </header>
34
+ <main id="main" class="workspace" tabindex="-1" aria-busy="${state.busy}"><div class="page">
35
+ ${state.snapshot.warning ? `<div class="banner" role="alert">${icon("warning")}<span>${h(state.snapshot.warning)}</span></div>` : ""}
36
+ ${content}
37
+ </div></main>
38
+ <footer class="statusbar"><span class="statusbar-left">${state.busy ? '<span class="spinner"></span>' : icon("checkCircle")}<span>${state.busy ? t("正在处理…", "Working…") : state.lastSaved ? t("更改已保存", "Changes saved") : t("本地配置已加载", "Local configuration loaded")}</span><code title="${h(state.snapshot.paths.providers)}">${h(state.snapshot.paths.providers)}</code></span><span class="statusbar-right">${icon(getTheme() === "light" ? "sun" : "moon")}${themeName()}</span></footer>
39
+ </div>`;
40
+ }
41
+
42
+ export function pageHeader(title, description = "", actions = "") {
43
+ return `<div class="page-header${description ? "" : " compact"}"><div><h1>${title}</h1>${description ? `<p>${description}</p>` : ""}</div>${actions ? `<div class="header-actions">${actions}</div>` : ""}</div>`;
44
+ }
45
+
46
+ export function contextHelp(items) {
47
+ return '<div class="context-help">' + items.map(([key, label]) => "<span><kbd>" + h(key) + "</kbd>" + label + "</span>").join("") + "</div>";
48
+ }