pi-extensions 0.1.22 → 0.1.24
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/.github/workflows/skill-apply-optimize.yml +19 -0
- package/.github/workflows/skill-review.yml +17 -0
- package/extending-pi/SKILL.md +43 -12
- package/package.json +2 -6
- package/usage-extension/CHANGELOG.md +8 -0
- package/usage-extension/README.md +4 -2
- package/usage-extension/index.ts +94 -82
- package/usage-extension/package.json +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Apply Skill Optimization
|
|
2
|
+
on:
|
|
3
|
+
issue_comment:
|
|
4
|
+
types: [created]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
apply:
|
|
8
|
+
if: >-
|
|
9
|
+
github.event.issue.pull_request &&
|
|
10
|
+
contains(github.event.comment.body, '/apply-optimize')
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
pull-requests: write
|
|
14
|
+
contents: write
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: tesslio/skill-review-and-optimize@d81583861aaf29d1da7f10e6539efef4e27b0dd5
|
|
18
|
+
with:
|
|
19
|
+
mode: 'apply'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: Skill Review & Optimize
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
paths: ['**/SKILL.md']
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
review:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
permissions:
|
|
10
|
+
pull-requests: write
|
|
11
|
+
contents: read
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: tesslio/skill-review-and-optimize@d81583861aaf29d1da7f10e6539efef4e27b0dd5
|
|
15
|
+
with:
|
|
16
|
+
optimize: 'true'
|
|
17
|
+
tessl-token: ${{ secrets.TESSL_API_TOKEN }}
|
package/extending-pi/SKILL.md
CHANGED
|
@@ -1,30 +1,61 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: extending-pi
|
|
3
|
-
description: Guide for extending Pi — decide between skills, extensions, prompt templates, themes, context files, or custom models, then
|
|
3
|
+
description: Guide for extending Pi — decide between skills, extensions, prompt templates, themes, context files, or custom models, then scaffold files, configure manifests, and package them. Use when someone wants to extend Pi, add capabilities, create a skill, build an extension, make a Pi package, scaffold extension files, or configure a manifest.json.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Extending Pi
|
|
7
7
|
|
|
8
|
-
Help the user decide what to build and
|
|
8
|
+
Help the user decide what to build, scaffold the right files, and point to detailed guidance.
|
|
9
9
|
|
|
10
10
|
## What to build
|
|
11
11
|
|
|
12
|
-
| Goal | Build a… | Where |
|
|
13
|
-
|
|
14
|
-
| Teach Pi a workflow or how to use a tool/API/CLI | **Skill** | Read `skill-creator/SKILL.md` for detailed guidance |
|
|
15
|
-
| Give Pi a new tool, command, or runtime behavior | **Extension** | Read Pi docs: `docs/extensions.md` |
|
|
16
|
-
| Reuse a prompt pattern with variables | **Prompt template** | Read Pi docs: `docs/prompt-templates.md` |
|
|
17
|
-
| Set project-wide coding guidelines | **Context file** | `AGENTS.md` in project root or `.pi/agent/` — just markdown |
|
|
18
|
-
| Change Pi's appearance | **Theme** | Read Pi docs: `docs/themes.md` |
|
|
19
|
-
| Add a model or provider | **Custom model** | Read Pi docs: `docs/models.md` (JSON) or `docs/custom-provider.md` (extension) |
|
|
20
|
-
| Share any of the above | **Package** | Read Pi docs: `docs/packages.md` |
|
|
12
|
+
| Goal | Build a… | Key files to create | Where |
|
|
13
|
+
|------|----------|-------------------|-------|
|
|
14
|
+
| Teach Pi a workflow or how to use a tool/API/CLI | **Skill** | `SKILL.md` with YAML frontmatter + markdown body | Read `skill-creator/SKILL.md` for detailed guidance |
|
|
15
|
+
| Give Pi a new tool, command, or runtime behavior | **Extension** | `manifest.json` + `src/index.ts` entry point | Read Pi docs: `docs/extensions.md` |
|
|
16
|
+
| Reuse a prompt pattern with variables | **Prompt template** | `.md` file with `{{variable}}` placeholders | Read Pi docs: `docs/prompt-templates.md` |
|
|
17
|
+
| Set project-wide coding guidelines | **Context file** | `AGENTS.md` in project root or `.pi/agent/` — just markdown | No extra docs needed |
|
|
18
|
+
| Change Pi's appearance | **Theme** | `theme.json` with color and font definitions | Read Pi docs: `docs/themes.md` |
|
|
19
|
+
| Add a model or provider | **Custom model** | `models.json` or extension with provider registration | Read Pi docs: `docs/models.md` (JSON) or `docs/custom-provider.md` (extension) |
|
|
20
|
+
| Share any of the above | **Package** | `manifest.json` with dependencies and entry points | Read Pi docs: `docs/packages.md` |
|
|
21
21
|
|
|
22
22
|
## Skill vs Extension — the fuzzy boundary
|
|
23
23
|
|
|
24
|
-
If `bash` + instructions can do it, prefer a **
|
|
24
|
+
If `bash` + instructions can do it, prefer a **Skill** (simpler, no code to maintain). If you need event hooks, typed tools, UI components, or policy enforcement, use an **Extension**.
|
|
25
25
|
|
|
26
26
|
Examples:
|
|
27
27
|
- "Pi should know our deploy process" → **Skill** (workflow instructions)
|
|
28
28
|
- "Pi should confirm before `rm -rf`" → **Extension** (event interception)
|
|
29
29
|
- "Pi should use Brave Search" → **Skill** (instructions + CLI scripts)
|
|
30
30
|
- "Pi should have a structured `db_query` tool" → **Extension** (registerTool)
|
|
31
|
+
|
|
32
|
+
## Minimal working examples
|
|
33
|
+
|
|
34
|
+
**Skill** — place in `.pi/skills/my-skill/SKILL.md`:
|
|
35
|
+
```markdown
|
|
36
|
+
---
|
|
37
|
+
name: my-skill
|
|
38
|
+
description: Does X when the user asks to Y.
|
|
39
|
+
---
|
|
40
|
+
# My Skill
|
|
41
|
+
Step 1: ...
|
|
42
|
+
Step 2: ...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Extension** — create `manifest.json` + `src/index.ts`:
|
|
46
|
+
```json
|
|
47
|
+
{ "name": "my-extension", "version": "0.1.0", "entry": "src/index.ts" }
|
|
48
|
+
```
|
|
49
|
+
```typescript
|
|
50
|
+
import { registerTool } from "@anthropic/pi-sdk";
|
|
51
|
+
registerTool("my_tool", { description: "..." }, async (input) => { /* ... */ });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick-start steps
|
|
55
|
+
|
|
56
|
+
1. **Pick the artifact type** from the table above.
|
|
57
|
+
2. **Scaffold the files** — create the key files using the minimal examples above.
|
|
58
|
+
3. **Validate locally**:
|
|
59
|
+
- Skills: place `SKILL.md` in `.pi/skills/<name>/` and invoke the skill — if it doesn't trigger, check that `name` and `description` in frontmatter are set correctly.
|
|
60
|
+
- Extensions: run `pi ext install .` — if it fails with "missing entry", verify the `entry` path in `manifest.json` points to an existing file.
|
|
61
|
+
4. **Package and share** — follow `docs/packages.md` to bundle and publish.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": [
|
|
@@ -15,15 +15,11 @@
|
|
|
15
15
|
"./arcade/tetris.ts",
|
|
16
16
|
"./arcade/mario-not/mario-not.ts",
|
|
17
17
|
"./code-actions/index.ts",
|
|
18
|
-
"./control/control.ts",
|
|
19
18
|
"./files-widget/index.ts",
|
|
20
19
|
"./raw-paste/index.ts",
|
|
21
20
|
"./ralph-wiggum/index.ts",
|
|
22
|
-
"./review/review.ts",
|
|
23
|
-
"./relaunch/index.ts",
|
|
24
21
|
"./tab-status/tab-status.ts",
|
|
25
|
-
"./usage-extension/index.ts"
|
|
26
|
-
"./ready-status/ready-status.ts"
|
|
22
|
+
"./usage-extension/index.ts"
|
|
27
23
|
],
|
|
28
24
|
"skills": [
|
|
29
25
|
"./extending-pi/SKILL.md",
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.5 - 2026-04-09
|
|
4
|
+
- Keep recursive subagent session scanning in `/usage`
|
|
5
|
+
- Remove the deduped/raw mode toggle and keep the deduped view as the default behavior
|
|
6
|
+
|
|
7
|
+
## 0.1.4 - 2026-04-09
|
|
8
|
+
- Scan session files recursively so nested subagent runs are included in `/usage`
|
|
9
|
+
- Add deduped vs raw counting modes to compare copied branch history against raw file totals
|
|
10
|
+
|
|
3
11
|
## 0.1.3 - 2026-02-03
|
|
4
12
|
- Add preview image metadata for the extension listing.
|
|
5
13
|
|
|
@@ -7,7 +7,7 @@ A Pi extension that displays aggregated usage statistics across all sessions.
|
|
|
7
7
|
## Compatibility
|
|
8
8
|
|
|
9
9
|
- **Pi version:** 0.42.4+
|
|
10
|
-
- **Last updated:** 2026-
|
|
10
|
+
- **Last updated:** 2026-04-09
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -111,7 +111,9 @@ The "Cache" column combines both read and write tokens.
|
|
|
111
111
|
|
|
112
112
|
## Data Source
|
|
113
113
|
|
|
114
|
-
Statistics are parsed from session files in `~/.pi/agent/sessions
|
|
114
|
+
Statistics are parsed recursively from session files in `~/.pi/agent/sessions/`, including nested subagent runs such as `run-0/` directories. Each session is a JSONL file containing message entries with usage data.
|
|
115
|
+
|
|
116
|
+
Assistant messages duplicated across branched session files are deduplicated by timestamp + total tokens, matching the extension's previous behavior while still including recursive subagent sessions.
|
|
115
117
|
|
|
116
118
|
Respects the `PI_CODING_AGENT_DIR` environment variable if set.
|
|
117
119
|
|
package/usage-extension/index.ts
CHANGED
|
@@ -96,31 +96,27 @@ function getSessionsDir(): string {
|
|
|
96
96
|
return join(agentDir, "sessions");
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
async function
|
|
100
|
-
const sessionsDir = getSessionsDir();
|
|
101
|
-
const files: string[] = [];
|
|
102
|
-
|
|
99
|
+
async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
|
|
103
100
|
try {
|
|
104
|
-
const
|
|
105
|
-
for (const
|
|
106
|
-
if (signal?.aborted) return
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (file.endsWith(".jsonl")) {
|
|
113
|
-
files.push(join(cwdPath, file));
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
// Skip directories we can't read
|
|
101
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (signal?.aborted) return;
|
|
104
|
+
const entryPath = join(dir, entry.name);
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
await collectSessionFilesRecursively(entryPath, files, signal);
|
|
107
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
108
|
+
files.push(entryPath);
|
|
118
109
|
}
|
|
119
110
|
}
|
|
120
111
|
} catch {
|
|
121
|
-
//
|
|
112
|
+
// Skip directories we can't read
|
|
122
113
|
}
|
|
114
|
+
}
|
|
123
115
|
|
|
116
|
+
async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
|
|
117
|
+
const files: string[] = [];
|
|
118
|
+
await collectSessionFilesRecursively(getSessionsDir(), files, signal);
|
|
119
|
+
files.sort();
|
|
124
120
|
return files;
|
|
125
121
|
}
|
|
126
122
|
|
|
@@ -135,11 +131,16 @@ interface SessionMessage {
|
|
|
135
131
|
timestamp: number;
|
|
136
132
|
}
|
|
137
133
|
|
|
134
|
+
interface ParsedSessionFile {
|
|
135
|
+
sessionId: string;
|
|
136
|
+
messages: SessionMessage[];
|
|
137
|
+
}
|
|
138
|
+
|
|
138
139
|
async function parseSessionFile(
|
|
139
140
|
filePath: string,
|
|
140
141
|
seenHashes: Set<string>,
|
|
141
142
|
signal?: AbortSignal
|
|
142
|
-
): Promise<
|
|
143
|
+
): Promise<ParsedSessionFile | null> {
|
|
143
144
|
try {
|
|
144
145
|
const content = await readFile(filePath, "utf8");
|
|
145
146
|
if (signal?.aborted) return null;
|
|
@@ -169,8 +170,8 @@ async function parseSessionFile(
|
|
|
169
170
|
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
170
171
|
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
171
172
|
|
|
172
|
-
// Deduplicate
|
|
173
|
-
//
|
|
173
|
+
// Deduplicate copied history across branched session files.
|
|
174
|
+
// Keep the existing ccusage-style hash so current totals remain comparable.
|
|
174
175
|
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
175
176
|
const hash = `${timestamp}:${totalTokens}`;
|
|
176
177
|
if (seenHashes.has(hash)) continue;
|
|
@@ -232,6 +233,73 @@ function emptyTimeFilteredStats(): TimeFilteredStats {
|
|
|
232
233
|
};
|
|
233
234
|
}
|
|
234
235
|
|
|
236
|
+
function emptyUsageData(): UsageData {
|
|
237
|
+
return {
|
|
238
|
+
today: emptyTimeFilteredStats(),
|
|
239
|
+
thisWeek: emptyTimeFilteredStats(),
|
|
240
|
+
allTime: emptyTimeFilteredStats(),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number): TabName[] {
|
|
245
|
+
const periods: TabName[] = ["allTime"];
|
|
246
|
+
if (timestamp >= todayMs) periods.push("today");
|
|
247
|
+
if (timestamp >= weekStartMs) periods.push("thisWeek");
|
|
248
|
+
return periods;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function addMessagesToUsageData(
|
|
252
|
+
data: UsageData,
|
|
253
|
+
sessionId: string,
|
|
254
|
+
messages: SessionMessage[],
|
|
255
|
+
todayMs: number,
|
|
256
|
+
weekStartMs: number
|
|
257
|
+
): void {
|
|
258
|
+
const sessionContributed = { today: false, thisWeek: false, allTime: false };
|
|
259
|
+
|
|
260
|
+
for (const msg of messages) {
|
|
261
|
+
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs);
|
|
262
|
+
const tokens = {
|
|
263
|
+
// Total = input + output only. cacheRead/cacheWrite are tracked separately.
|
|
264
|
+
// cacheRead tokens were already counted when first sent, so including them
|
|
265
|
+
// would double-count and massively inflate totals (cache hits repeat every message).
|
|
266
|
+
total: msg.input + msg.output,
|
|
267
|
+
input: msg.input,
|
|
268
|
+
output: msg.output,
|
|
269
|
+
cache: msg.cacheRead + msg.cacheWrite,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
for (const period of periods) {
|
|
273
|
+
const stats = data[period];
|
|
274
|
+
|
|
275
|
+
let providerStats = stats.providers.get(msg.provider);
|
|
276
|
+
if (!providerStats) {
|
|
277
|
+
providerStats = emptyProviderStats();
|
|
278
|
+
stats.providers.set(msg.provider, providerStats);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let modelStats = providerStats.models.get(msg.model);
|
|
282
|
+
if (!modelStats) {
|
|
283
|
+
modelStats = emptyModelStats();
|
|
284
|
+
providerStats.models.set(msg.model, modelStats);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
modelStats.sessions.add(sessionId);
|
|
288
|
+
accumulateStats(modelStats, msg.cost, tokens);
|
|
289
|
+
|
|
290
|
+
providerStats.sessions.add(sessionId);
|
|
291
|
+
accumulateStats(providerStats, msg.cost, tokens);
|
|
292
|
+
|
|
293
|
+
accumulateStats(stats.totals, msg.cost, tokens);
|
|
294
|
+
sessionContributed[period] = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (sessionContributed.today) data.today.totals.sessions++;
|
|
299
|
+
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
300
|
+
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
301
|
+
}
|
|
302
|
+
|
|
235
303
|
async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
|
|
236
304
|
const startOfToday = new Date();
|
|
237
305
|
startOfToday.setHours(0, 0, 0, 0);
|
|
@@ -245,15 +313,11 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
245
313
|
startOfWeek.setHours(0, 0, 0, 0);
|
|
246
314
|
const weekStartMs = startOfWeek.getTime();
|
|
247
315
|
|
|
248
|
-
const data
|
|
249
|
-
today: emptyTimeFilteredStats(),
|
|
250
|
-
thisWeek: emptyTimeFilteredStats(),
|
|
251
|
-
allTime: emptyTimeFilteredStats(),
|
|
252
|
-
};
|
|
316
|
+
const data = emptyUsageData();
|
|
253
317
|
|
|
254
318
|
const sessionFiles = await getAllSessionFiles(signal);
|
|
255
319
|
if (signal?.aborted) return null;
|
|
256
|
-
const seenHashes = new Set<string>();
|
|
320
|
+
const seenHashes = new Set<string>();
|
|
257
321
|
|
|
258
322
|
for (const filePath of sessionFiles) {
|
|
259
323
|
if (signal?.aborted) return null;
|
|
@@ -261,59 +325,7 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
261
325
|
if (signal?.aborted) return null;
|
|
262
326
|
if (!parsed) continue;
|
|
263
327
|
|
|
264
|
-
|
|
265
|
-
const sessionContributed = { today: false, thisWeek: false, allTime: false };
|
|
266
|
-
|
|
267
|
-
for (const msg of messages) {
|
|
268
|
-
if (signal?.aborted) return null;
|
|
269
|
-
const periods: TabName[] = ["allTime"];
|
|
270
|
-
if (msg.timestamp >= todayMs) periods.push("today");
|
|
271
|
-
if (msg.timestamp >= weekStartMs) periods.push("thisWeek");
|
|
272
|
-
|
|
273
|
-
const tokens = {
|
|
274
|
-
// Total = input + output only. cacheRead/cacheWrite are tracked separately.
|
|
275
|
-
// cacheRead tokens were already counted when first sent, so including them
|
|
276
|
-
// would double-count and massively inflate totals (cache hits repeat every message).
|
|
277
|
-
total: msg.input + msg.output,
|
|
278
|
-
input: msg.input,
|
|
279
|
-
output: msg.output,
|
|
280
|
-
cache: msg.cacheRead + msg.cacheWrite,
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
for (const period of periods) {
|
|
284
|
-
const stats = data[period];
|
|
285
|
-
|
|
286
|
-
// Get or create provider stats
|
|
287
|
-
let providerStats = stats.providers.get(msg.provider);
|
|
288
|
-
if (!providerStats) {
|
|
289
|
-
providerStats = emptyProviderStats();
|
|
290
|
-
stats.providers.set(msg.provider, providerStats);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Get or create model stats
|
|
294
|
-
let modelStats = providerStats.models.get(msg.model);
|
|
295
|
-
if (!modelStats) {
|
|
296
|
-
modelStats = emptyModelStats();
|
|
297
|
-
providerStats.models.set(msg.model, modelStats);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Accumulate stats at all levels
|
|
301
|
-
modelStats.sessions.add(sessionId);
|
|
302
|
-
accumulateStats(modelStats, msg.cost, tokens);
|
|
303
|
-
|
|
304
|
-
providerStats.sessions.add(sessionId);
|
|
305
|
-
accumulateStats(providerStats, msg.cost, tokens);
|
|
306
|
-
|
|
307
|
-
accumulateStats(stats.totals, msg.cost, tokens);
|
|
308
|
-
|
|
309
|
-
sessionContributed[period] = true;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Count unique sessions per period
|
|
314
|
-
if (sessionContributed.today) data.today.totals.sessions++;
|
|
315
|
-
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
316
|
-
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
328
|
+
addMessagesToUsageData(data, parsed.sessionId, parsed.messages, todayMs, weekStartMs);
|
|
317
329
|
|
|
318
330
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
319
331
|
}
|
|
@@ -463,7 +475,7 @@ class UsageComponent {
|
|
|
463
475
|
const label = TAB_LABELS[tab];
|
|
464
476
|
return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
|
|
465
477
|
}).join(" ");
|
|
466
|
-
return [tabs, ""];
|
|
478
|
+
return [tabs, th.fg("dim", "Dedupes copied branched-history messages. Recursive subagent sessions included."), ""];
|
|
467
479
|
}
|
|
468
480
|
|
|
469
481
|
private renderHeader(): string[] {
|