pi-skill-evolve 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
  2. package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
  3. package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
  4. package/extensions/skill-evolve/lib/web/render-rows.ts +177 -235
  5. package/extensions/skill-evolve/lib/web/render-session.ts +105 -34
  6. package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
  7. package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
  8. package/extensions/skill-evolve/lib/web/render.ts +116 -54
  9. package/extensions/skill-evolve/lib/web/server.ts +9 -1
  10. package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
  11. package/extensions/skill-evolve/lib/web/session-view.ts +100 -84
  12. package/extensions/skill-evolve/lib/web/shell.ts +42 -11
  13. package/extensions/skill-evolve/lib/web/style.ts +171 -106
  14. package/extensions/skill-evolve/lib/web/text.ts +57 -0
  15. package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
  16. package/extensions/skill-evolve/test/session-page.test.ts +11 -5
  17. package/extensions/skill-evolve/test/session-render.test.ts +203 -0
  18. package/extensions/skill-evolve/test/session-view.test.ts +75 -87
  19. package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
  20. package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
  21. package/extensions/skill-evolve/test/web-render.test.ts +57 -41
  22. package/extensions/skill-evolve/test/web-server.test.ts +4 -1
  23. package/extensions/skill-evolve/test/web-session-column.test.ts +30 -32
  24. package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
  25. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 会话详情页的**纯函数**:把 pi 的会话 JSONL 解析成「可渲染的块」,再拼成 HTML 片段。
2
+ * 会话详情页的**纯函数**:把 pi 的会话 JSONL 解析成「可渲染的块」,再拼成 HTML 片段与目录。
3
3
  *
4
4
  * 为什么要这一页(用户 2026-11-08:「支持打开会话的详情, 工具调用可收起展开, 默认收起」):
5
5
  * 面板上每条 run 都关联一段 pi 会话,出问题时要看的是「那段对话里到底发生了什么」——
@@ -9,14 +9,15 @@
9
9
  * - 只取顶层 `type === "message"` 的条目,其余(session / model_change / thinking_level_change / custom)全跳过;
10
10
  * - `message.role` = user / assistant / toolResult / system;system 不渲染(它是系统提示词,不是对话);
11
11
  * - assistant 的内容里 `text` 是给人看的、`thinking` 不渲染、`toolCall` 聚成折叠块;
12
- * - toolResult 消息按 `toolCallId` 回连到那次调用(结果贴在该调用的参数之后)。
12
+ * - toolResult 消息按 `toolCallId` 回连到那次调用(结果贴在该调用的参数之后);
13
+ * - 时间取该条 JSONL 记录顶层的 `timestamp`(ISO 字符串),拿不到就是 null。
13
14
  *
14
15
  * 三条上限(会话可能很大,页面不能被一段对话撑爆浏览器):
15
16
  * 只渲染最后 200 条消息 · 单条 4000 字 · 用户原文 2000 字 · 总输出超 2MB 就停手并指路原文件。
16
17
  *
17
- * 这个文件里**没有一行 IO**:入参是字符串,出参是 HTML 字符串,所以能直接拿临时 JSONL 片段做单测。
18
+ * 这个文件里**没有一行 IO**:入参是字符串,出参是数据结构,所以能直接拿临时 JSONL 片段做单测。
19
+ * HTML 与目录在 ./session-render.ts —— 解析与渲染各自都够一屏,混在一起必然破 300 行。
18
20
  */
19
- import { escapeHtml } from "./text.ts";
20
21
 
21
22
  /** 只渲染最后多少条 message(更早的对话对排查当前问题几乎无用) */
22
23
  export const MAX_MESSAGES = 200;
@@ -24,8 +25,10 @@ export const MAX_MESSAGES = 200;
24
25
  export const MAX_TEXT = 4000;
25
26
  /** 用户消息正文上限:用户要的是「原文」,所以给得比 assistant 宽,但仍要防一次粘贴几万字 */
26
27
  export const MAX_USER_TEXT = 2000;
27
- /** 一次工具调用的参数摘要上限 */
28
+ /** 一次工具调用的参数摘要上限(目录与折叠抬头只用 60 字,见 `TOOL_PREVIEW`) */
28
29
  export const MAX_ARGS = 400;
30
+ /** 折叠抬头里那句「调了什么」的上限:一行看得完,不抢工具名的位置 */
31
+ export const TOOL_SUMMARY = 60;
29
32
  /** 一次工具调用的结果上限 */
30
33
  export const MAX_RESULT = 200;
31
34
  /** 整页会话正文的输出上限(字符数,UTF-8 下中文 3 字节,这里按保守值算) */
@@ -33,16 +36,18 @@ export const MAX_OUTPUT = 2 * 1024 * 1024;
33
36
 
34
37
  export interface SessionToolCall {
35
38
  name: string;
36
- /** 参数摘要(JSON 压成一行后截断) */
39
+ /** 参数原文(JSON 压成一行后截断):展开后给「完整参数」看 */
37
40
  args: string;
41
+ /** 抬头摘要:按工具挑一个可读参数(见 `toolArgSummary`),取不到就是空串 */
42
+ summary: string;
38
43
  /** 结果前 200 字(没有结果就是「(没有结果)」) */
39
44
  result: string;
40
45
  }
41
46
 
42
47
  export type SessionEntry =
43
- | { kind: "user"; text: string }
44
- | { kind: "assistant"; text: string }
45
- | { kind: "tools"; calls: SessionToolCall[] };
48
+ | { kind: "user"; text: string; at: number | null }
49
+ | { kind: "assistant"; text: string; at: number | null }
50
+ | { kind: "tools"; calls: SessionToolCall[]; at: number | null };
46
51
 
47
52
  /**
48
53
  * 解析中途的调用记录:结果(toolResult)总是排在携带 toolCall 的 assistant 消息**之后**,
@@ -52,6 +57,7 @@ interface RawCall {
52
57
  id: string;
53
58
  name: string;
54
59
  args: string;
60
+ summary: string;
55
61
  }
56
62
 
57
63
  export interface SessionTranscript {
@@ -62,6 +68,9 @@ export interface SessionTranscript {
62
68
  toolCalls: number;
63
69
  /** 因为只留最后 200 条而丢掉的消息条数 */
64
70
  dropped: number;
71
+ /** 第一条 / 最后一条消息的时间:概览卡片的「10:32 → 11:52」用它 */
72
+ firstAt: number | null;
73
+ lastAt: number | null;
65
74
  }
66
75
 
67
76
  /** 截断并加省略号:中文一行话也可能超长,所以在字符数上截,不在行上截 */
@@ -70,6 +79,15 @@ function clip(text: string, max: number): string {
70
79
  return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
71
80
  }
72
81
 
82
+ /** 顶层时间戳:ISO 字符串或已是数字;解析不了就当没有(不拿当前时间冒充) */
83
+ function stamp(record: Record<string, unknown>): number | null {
84
+ const raw = record.timestamp;
85
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
86
+ if (typeof raw !== "string") return null;
87
+ const parsed = Date.parse(raw);
88
+ return Number.isFinite(parsed) ? parsed : null;
89
+ }
90
+
73
91
  /** 参数压成一行 JSON:多行缩进在面板里只会占满屏 */
74
92
  function argumentsText(value: unknown): string {
75
93
  let text: string;
@@ -85,6 +103,55 @@ function argumentsText(value: unknown): string {
85
103
  return clip(text.replace(/\s+/g, " "), MAX_ARGS);
86
104
  }
87
105
 
106
+ /** bash 的 command:只要第一行(多行脚本会把抬头撑成一屏),`\n` 还原成空格,首尾引号去掉 */
107
+ function commandHead(command: string | null): string | null {
108
+ if (command === null) return null;
109
+ const firstLine = command.split("\n")[0] ?? "";
110
+ return firstLine.replace(/\\n/g, " ").replace(/^[\s"'`]+|[\s"'`]+$/g, "");
111
+ }
112
+
113
+ /** 路径的文件名:`read /home/x/a.ts` 的抬头只值 `a.ts`,整条路径会把抬头挤满 */
114
+ function basenameOf(path: string): string {
115
+ const parts = path.split("/").filter((part) => part.length > 0);
116
+ return parts.length > 0 ? (parts[parts.length - 1] as string) : path;
117
+ }
118
+
119
+ /** 参数对象里第一个非空字符串值:没有统一约定的工具就取它,总比一串 JSON 强 */
120
+ function firstString(record: Record<string, unknown> | null): string | null {
121
+ if (record === null) return null;
122
+ for (const value of Object.values(record)) {
123
+ if (typeof value === "string" && value.length > 0) return value;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * 折叠抬头的参数摘要:**按工具取那一个可读参数**,绝不把整段 JSON 泄出来。
130
+ * `bash` → command 第一行;`read`/`edit`/`write` → path 的文件名;`rg`/`grep` → pattern;
131
+ * `compress` → 没有单一可读参数(整段对话都在入参里),留空只显示 `×N`;
132
+ * 其它工具取第一个字符串参数。取不到一律返回空串(抬头就只剩 `×N`)。
133
+ * 参数本身是裸字符串时(少数工具直接传字符串)就把它当那一个参数。
134
+ */
135
+ export function toolArgSummary(name: string, args: unknown): string {
136
+ if (name === "compress") return "";
137
+ const record = typeof args === "object" && args !== null && !Array.isArray(args)
138
+ ? (args as Record<string, unknown>)
139
+ : null;
140
+ const single = typeof args === "string" ? args : null;
141
+ const field = (key: string): string | null => {
142
+ const value = record === null ? null : record[key];
143
+ return typeof value === "string" ? value : null;
144
+ };
145
+ let picked: string | null;
146
+ if (name === "bash") picked = commandHead(field("command") ?? single);
147
+ else if (name === "read" || name === "edit" || name === "write") {
148
+ const path = field("path") ?? single;
149
+ picked = path === null ? null : basenameOf(path);
150
+ } else if (name === "rg" || name === "grep") picked = field("pattern") ?? single;
151
+ else picked = firstString(record) ?? single;
152
+ return picked === null ? "" : clip(picked, TOOL_SUMMARY);
153
+ }
154
+
88
155
  /** toolResult / user 的 content:可能是字符串,也可能是 [{type:"text",text}],图片只留一句占位 */
89
156
  function contentText(content: unknown): string {
90
157
  if (typeof content === "string") return content;
@@ -115,10 +182,12 @@ function readAssistantParts(content: unknown): { text: string; calls: RawCall[]
115
182
  const record = part as Record<string, unknown>;
116
183
  if (record.type === "text" && typeof record.text === "string") texts.push(record.text);
117
184
  else if (record.type === "toolCall") {
185
+ const name = typeof record.name === "string" && record.name.length > 0 ? record.name : "(未命名工具)";
118
186
  calls.push({
119
187
  id: typeof record.id === "string" ? record.id : "",
120
- name: typeof record.name === "string" ? record.name : "(未命名工具)",
188
+ name,
121
189
  args: argumentsText(record.arguments),
190
+ summary: toolArgSummary(name, record.arguments),
122
191
  });
123
192
  }
124
193
  }
@@ -127,9 +196,10 @@ function readAssistantParts(content: unknown): { text: string; calls: RawCall[]
127
196
 
128
197
  /** 解析整份 JSONL:坏行跳过(可能是进程被 kill 时写了一半的最后一行) */
129
198
  export function parseSessionText(raw: string): SessionTranscript {
130
- type Pending = { entry: SessionEntry | { kind: "tools"; calls: RawCall[] }; roles: string[] };
199
+ type Pending = { entry: SessionEntry | { kind: "tools"; calls: RawCall[]; at: number | null }; roles: string[] };
131
200
  const kept: Pending[] = [];
132
201
  const results = new Map<string, string>();
202
+ const stamps: number[] = [];
133
203
  let messages = 0;
134
204
  for (const line of raw.split(/\r?\n/)) {
135
205
  if (line.trim().length === 0) continue;
@@ -144,7 +214,9 @@ export function parseSessionText(raw: string): SessionTranscript {
144
214
  if (typeof message !== "object" || message === null) continue;
145
215
  const fields = message as Record<string, unknown>;
146
216
  const role = typeof fields.role === "string" ? fields.role : "";
217
+ const at = stamp(record);
147
218
  messages += 1;
219
+ if (at !== null) stamps.push(at);
148
220
  if (role === "toolResult") {
149
221
  // 结果先存起来:它总是排在携带 toolCall 的那条 assistant 消息之后
150
222
  const id = typeof fields.toolCallId === "string" ? fields.toolCallId : "";
@@ -152,7 +224,7 @@ export function parseSessionText(raw: string): SessionTranscript {
152
224
  continue;
153
225
  }
154
226
  if (role === "user") {
155
- kept.push({ entry: { kind: "user", text: contentText(fields.content) }, roles: [role] });
227
+ kept.push({ entry: { kind: "user", text: contentText(fields.content), at }, roles: [role] });
156
228
  continue;
157
229
  }
158
230
  if (role !== "assistant") continue; // system 是系统提示词,不是对话
@@ -160,8 +232,11 @@ export function parseSessionText(raw: string): SessionTranscript {
160
232
  if (parts.text.trim().length > 0) {
161
233
  const last = kept[kept.length - 1];
162
234
  // 一条 assistant 消息里「文本 + toolCall」并存:文本先出,工具另起一段,顺序与真实发生一致
163
- if (last && last.roles[last.roles.length - 1] === "assistant-text") last.entry = { kind: "assistant", text: `${(last.entry as { text: string }).text}\n${parts.text}` };
164
- else kept.push({ entry: { kind: "assistant", text: parts.text }, roles: ["assistant-text"] });
235
+ if (last && last.roles[last.roles.length - 1] === "assistant-text") {
236
+ last.entry = { kind: "assistant", text: `${(last.entry as { text: string }).text}\n${parts.text}`, at };
237
+ } else {
238
+ kept.push({ entry: { kind: "assistant", text: parts.text, at }, roles: ["assistant-text"] });
239
+ }
165
240
  }
166
241
  if (parts.calls.length > 0) {
167
242
  const last = kept[kept.length - 1];
@@ -169,7 +244,7 @@ export function parseSessionText(raw: string): SessionTranscript {
169
244
  if (last && last.entry.kind === "tools" && last.roles[last.roles.length - 1] !== "assistant-text") {
170
245
  (last.entry.calls as RawCall[]).push(...parts.calls);
171
246
  } else {
172
- kept.push({ entry: { kind: "tools", calls: [...parts.calls] }, roles: ["tools"] });
247
+ kept.push({ entry: { kind: "tools", calls: [...parts.calls], at }, roles: ["tools"] });
173
248
  }
174
249
  }
175
250
  }
@@ -180,81 +255,22 @@ export function parseSessionText(raw: string): SessionTranscript {
180
255
  item.entry.kind === "tools"
181
256
  ? {
182
257
  kind: "tools",
258
+ at: item.entry.at,
183
259
  calls: (item.entry.calls as RawCall[]).map((call) => ({
184
260
  name: call.name,
185
261
  args: call.args,
262
+ summary: call.summary,
186
263
  result: results.get(call.id) ?? "(没有结果)",
187
264
  })),
188
265
  }
189
266
  : item.entry,
190
267
  );
191
- return { entries, messages, toolCalls, dropped };
192
- }
193
-
194
- /** 工具调用的抬头:`read ×3、bash ×2`(同名的合成一个计数,别写三遍 read) */
195
- function toolSummary(calls: readonly SessionToolCall[]): string {
196
- const counts = new Map<string, number>();
197
- for (const call of calls) counts.set(call.name, (counts.get(call.name) ?? 0) + 1);
198
- return [...counts.entries()].map(([name, count]) => `${name} ×${count}`).join("、");
199
- }
200
-
201
- /** 一个工具调用块:名字 + 参数摘要 + 结果前 200 字(全转义,original 里可能带尖括号) */
202
- function toolBlock(call: SessionToolCall): string {
203
- return `<div class="tool">
204
- <p class="tool-name">${escapeHtml(call.name)}</p>
205
- <pre class="tool-args">${escapeHtml(call.args.length > 0 ? call.args : "(无参数)")}</pre>
206
- <pre class="tool-result">${escapeHtml(call.result)}</pre>
207
- </div>`;
208
- }
209
-
210
- /**
211
- * 一组工具调用:`<details>` **默认收起**(不加 `open`)——
212
- * 一段会话动辄几百次调用,默认展开等于把对话正文顶到十屏之外。
213
- */
214
- function toolGroup(calls: readonly SessionToolCall[]): string {
215
- return `<details class="tools"><summary>工具调用:${escapeHtml(toolSummary(calls))}(点击展开)</summary>
216
- ${calls.map(toolBlock).join("\n")}
217
- </details>`;
218
- }
219
-
220
- /** 一条对话消息:user 给原文(2000 字上限),assistant 给纯文本(4000 字上限) */
221
- function messageBlock(entry: Extract<SessionEntry, { kind: "user" | "assistant" }>): string {
222
- const label = entry.kind === "user" ? "用户" : "助手";
223
- const max = entry.kind === "user" ? MAX_USER_TEXT : MAX_TEXT;
224
- const body = clip(entry.text, max);
225
- if (body.length === 0) return "";
226
- return `<div class="msg ${entry.kind}">
227
- <p class="who">${label}</p>
228
- <pre>${escapeHtml(body)}</pre>
229
- </div>`;
230
- }
231
-
232
- export interface RenderSessionOptions {
233
- /** 因「只渲染最后 200 条」丢掉的条数(>0 时在顶部说明一句) */
234
- dropped?: number;
235
- }
236
-
237
- /** 会话正文:按真实顺序渲染,超出总输出上限就停下并指路原文件 */
238
- export function renderSessionBody(transcript: SessionTranscript, options: RenderSessionOptions = {}): string {
239
- const parts: string[] = [];
240
- let size = 0;
241
- let stopped = false;
242
- if (options.dropped !== undefined && options.dropped > 0) {
243
- parts.push(`<p class="hint">只渲染最后 ${MAX_MESSAGES} 条消息,前面还有 ${options.dropped} 条未显示(看全文请用 rg/read 打开原文件)。</p>`);
244
- }
245
- for (const entry of transcript.entries) {
246
- const html = entry.kind === "tools" ? toolGroup(entry.calls) : messageBlock(entry);
247
- if (html.length === 0) continue;
248
- if (size + html.length > MAX_OUTPUT) {
249
- stopped = true;
250
- break;
251
- }
252
- parts.push(html);
253
- size += html.length;
254
- }
255
- if (stopped) {
256
- parts.push(`<p class="hint">已达页面输出上限(${Math.round(MAX_OUTPUT / 1024)}KB),后面还有内容未渲染:继续看请用 rg/read 打开原文件。</p>`);
257
- }
258
- if (parts.length === 0) return '<p class="empty">这段会话里没有可渲染的对话(可能全是工具调用与系统提示)。</p>';
259
- return parts.join("\n");
268
+ return {
269
+ entries,
270
+ messages,
271
+ toolCalls,
272
+ dropped,
273
+ firstAt: stamps.length === 0 ? null : Math.min(...stamps),
274
+ lastAt: stamps.length === 0 ? null : Math.max(...stamps),
275
+ };
260
276
  }
@@ -4,6 +4,9 @@
4
4
  * 为什么抽出来:run 详情页与技能详情页也要状态栏、六个 tab、内联样式,
5
5
  * 各自复制一遍必然出现「点进详情页 tab 高亮错页、状态栏数字少算一个」。
6
6
  * 这里只拼字符串:不读盘、不发请求、不引外部资源 —— 数据由调用方读好传进来。
7
+ *
8
+ * 顶部是**粘性区**(`position: sticky`):列表滚到第 30 行时,
9
+ * 「现在有几条要处理、我在哪个 tab、四个开关什么状态」仍然看得见 —— 这三样是这一页的全部决策输入。
7
10
  */
8
11
  import type { WebState, WebView } from "./data.ts";
9
12
  import { STYLE } from "./style.ts";
@@ -21,29 +24,50 @@ export const VIEW_LABEL: Record<TabView, string> = {
21
24
  skills: "技能管理",
22
25
  };
23
26
 
27
+ /** 每个 tab 的计数徽章;`skills` 没有计数(它不是「条数」) */
28
+ export type TabCounts = Partial<Record<TabView, number>>;
29
+
24
30
  /**
25
31
  * 当前高亮的 tab。详情页传 `null`:它们不在导航里(要 runId / 技能名才能进来),
26
32
  * 硬highlight 某个 tab 等于骗人 —— 点那个 tab 回不到这一页。
27
33
  */
28
34
  export type ActiveTab = WebView | "skills" | null;
29
35
 
30
- /** 导航:`<a href>` 只用枚举值拼,绝不把 query 原样回显 */
31
- export function tabs(active: ActiveTab): string {
36
+ /** 计数从状态里取:tab 上的数字与列表里的条数必须是同一份数据,不能各算一遍 */
37
+ export function tabCounts(state: WebState): TabCounts {
38
+ return {
39
+ pending: state.pending.length,
40
+ approvals: state.approvals.length,
41
+ // 已采纳用 summary 的累计值:列表本身有上限(50 条),拿它当总数会永远显示 50
42
+ applied: state.summary.appliedTotal,
43
+ done: state.done.length,
44
+ reports: state.reports.length,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * 导航:`<a href>` 只用枚举值拼,绝不把 query 原样回显。
50
+ * 不给 counts 时只出标签(详情页与单测用),给了就在标签后面挂一个计数徽章(`.cnt`)。
51
+ */
52
+ export function tabs(active: ActiveTab, counts: TabCounts = {}): string {
32
53
  return TAB_ORDER.map((item) => {
33
54
  const className = item === active ? ' class="active"' : "";
34
- return `<a href="/?view=${item}"${className}>${VIEW_LABEL[item]}</a>`;
55
+ const count = counts[item];
56
+ const badge = count === undefined ? "" : `<span class="cnt">${count}</span>`;
57
+ return `<a href="/?view=${item}"${className}>${VIEW_LABEL[item]}${badge}</a>`;
35
58
  }).join("\n");
36
59
  }
37
60
 
38
- /** 状态栏:全是数字,安全;「待批 N」「已采纳 N」都直接链到对应页(用户就是点那个数字的) */
61
+ /**
62
+ * 摘要行:四个数字。「待批」与「已采纳」直接链到对应页 —— 用户就是点那个数字的。
63
+ * 回滚率不在这里:它是周报口径的比率,挤进一行数字里只会让人以为「26% 也要现在处理」。
64
+ */
39
65
  export function statusBar(state: WebState): string {
40
- const percent = (state.summary.rollbackRate * 100).toFixed(0);
41
66
  return [
42
67
  `复盘中 ${state.summary.reviewing}`,
43
68
  `卡死 ${state.summary.stale}`,
44
69
  `待批 <a href="/?view=approvals">${state.summary.pendingApprovals}</a>`,
45
70
  `已采纳 <a href="/?view=applied">${state.summary.appliedTotal}</a>`,
46
- `回滚率 ${percent}%`,
47
71
  ].join(" · ");
48
72
  }
49
73
 
@@ -52,10 +76,12 @@ export interface ShellInput {
52
76
  /** 页面标题:进 `<title>`,页面上不再重复一遍 */
53
77
  title: string;
54
78
  active: ActiveTab;
55
- /** `<head>` 里的额外标签(目前只有待处理页的 5 秒自刷新) */
79
+ /** `<head>` 里的额外标签(目前只有 `&live=1` 时的 5 秒自刷新) */
56
80
  head?: string;
57
- /** 导航上方的「控制条 + 横幅」 */
81
+ /** 粘性区里、tabs 下面的那一行(列表页的四个开关与横幅) */
58
82
  top?: string;
83
+ /** 摘要行尾部的小字(列表页的「更新于 … · 刷新」) */
84
+ meta?: string;
59
85
  /** `<main>` 内容 */
60
86
  body: string;
61
87
  /** 页脚内容(列表页传「最近操作 + 只读说明」) */
@@ -63,6 +89,7 @@ export interface ShellInput {
63
89
  }
64
90
 
65
91
  export function shellPage(input: ShellInput): string {
92
+ const meta = input.meta === undefined || input.meta.length === 0 ? "" : ` · ${input.meta}`;
66
93
  return `<!doctype html>
67
94
  <html lang="zh-CN">
68
95
  <head>
@@ -72,15 +99,19 @@ ${input.head ?? ""}<title>技能进化 · ${input.title}</title>
72
99
  <style>${STYLE}</style>
73
100
  </head>
74
101
  <body>
102
+ <div class="wrap" id="top">
103
+ <header class="hd">
75
104
  <h1>技能进化面板</h1>
76
- <p class="bar">${statusBar(input.state)}</p>
77
- ${input.top ?? ""}<nav>
78
- ${tabs(input.active)}
105
+ <p class="sum">${statusBar(input.state)}${meta}</p>
106
+ <nav class="tabs">
107
+ ${tabs(input.active, tabCounts(input.state))}
79
108
  </nav>
109
+ ${input.top ?? ""}</header>
80
110
  ${input.body}
81
111
  <footer>
82
112
  ${input.foot ?? ""}
83
113
  </footer>
114
+ </div>
84
115
  </body>
85
116
  </html>
86
117
  `;