pi-idle 1.0.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/.github/workflows/test.yml +18 -0
- package/.gitleaks.toml +84 -0
- package/LICENSE +21 -0
- package/PLAN.md +89 -0
- package/README.md +40 -0
- package/package.json +15 -0
- package/pi-idle.ts +112 -0
- package/test.ts +208 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [master]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-node@v4
|
|
15
|
+
with:
|
|
16
|
+
node-version: 22
|
|
17
|
+
- run: npm install
|
|
18
|
+
- run: npm test
|
package/.gitleaks.toml
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Gitleaks configuration for pi-idle extension repository.
|
|
2
|
+
# This is a Node.js / TypeScript project with no secrets expected.
|
|
3
|
+
|
|
4
|
+
title = "pi-idle gitleaks config"
|
|
5
|
+
|
|
6
|
+
# Extend the default ruleset. Rules are defined inline below.
|
|
7
|
+
# We keep only the rules that are relevant for a small TS project
|
|
8
|
+
# and add custom allowlists to avoid false positives.
|
|
9
|
+
|
|
10
|
+
[allowlist]
|
|
11
|
+
description = "Global allowlist – paths and patterns to skip"
|
|
12
|
+
paths = [
|
|
13
|
+
# Standard ignores
|
|
14
|
+
".git/",
|
|
15
|
+
"node_modules/",
|
|
16
|
+
"package-lock.json",
|
|
17
|
+
"pnpm-lock.yaml",
|
|
18
|
+
"bun.lock",
|
|
19
|
+
"bun.lockb",
|
|
20
|
+
# Test files may contain fake credentials
|
|
21
|
+
"test.ts",
|
|
22
|
+
]
|
|
23
|
+
# Regex patterns to ignore globally
|
|
24
|
+
regexes = []
|
|
25
|
+
|
|
26
|
+
# ── Specific rules ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
[[rules]]
|
|
29
|
+
id = "generic-api-key"
|
|
30
|
+
description = "Generic API Key"
|
|
31
|
+
regex = '''(?i)(?:key|token|secret|password|credential|auth|bearer|apikey|api[_-]?key)(?:'s)?\s*(?:=|:)\s*['"]([0-9a-zA-Z\-_.+/]{8,})['"]'''
|
|
32
|
+
tags = ["key", "API", "generic"]
|
|
33
|
+
|
|
34
|
+
[[rules]]
|
|
35
|
+
id = "aws-access-key"
|
|
36
|
+
description = "AWS Access Key ID"
|
|
37
|
+
regex = '''(?:A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}'''
|
|
38
|
+
tags = ["key", "AWS"]
|
|
39
|
+
|
|
40
|
+
[[rules]]
|
|
41
|
+
id = "aws-secret-key"
|
|
42
|
+
description = "AWS Secret Key"
|
|
43
|
+
regex = '''(?i)aws[_-]?(?:secret|access)[_-]?key\s*(?:=|:)\s*['"]([A-Za-z0-9/\+=]{40})['"]'''
|
|
44
|
+
tags = ["key", "AWS"]
|
|
45
|
+
|
|
46
|
+
[[rules]]
|
|
47
|
+
id = "github-token"
|
|
48
|
+
description = "GitHub Personal Access Token"
|
|
49
|
+
regex = '''(?:ghp|gho|ghu|ghs|ghr)[A-Za-z0-9_]{36}'''
|
|
50
|
+
tags = ["key", "GitHub"]
|
|
51
|
+
|
|
52
|
+
[[rules]]
|
|
53
|
+
id = "github-fine-grained-token"
|
|
54
|
+
description = "GitHub Fine-Grained Access Token"
|
|
55
|
+
regex = '''github_pat_[A-Za-z0-9_]{82}'''
|
|
56
|
+
tags = ["key", "GitHub"]
|
|
57
|
+
|
|
58
|
+
[[rules]]
|
|
59
|
+
id = "google-api-key"
|
|
60
|
+
description = "Google API Key"
|
|
61
|
+
regex = '''AIza[0-9A-Za-z\-_]{35}'''
|
|
62
|
+
tags = ["key", "Google"]
|
|
63
|
+
|
|
64
|
+
[[rules]]
|
|
65
|
+
id = "slack-token"
|
|
66
|
+
description = "Slack Token"
|
|
67
|
+
regex = '''xox[baprs]-[0-9a-zA-Z\-]{10,72}'''
|
|
68
|
+
tags = ["key", "Slack"]
|
|
69
|
+
|
|
70
|
+
[[rules]]
|
|
71
|
+
id = "private-key"
|
|
72
|
+
description = "Private Key"
|
|
73
|
+
regex = '''-----BEGIN\s?(RSA|DSA|EC|OPENSSH|SSH2|PGP|PRIVATE)\s?KEY[-----]'''
|
|
74
|
+
tags = ["key", "private"]
|
|
75
|
+
|
|
76
|
+
[[rules]]
|
|
77
|
+
id = "npm-token"
|
|
78
|
+
description = "npm Access Token"
|
|
79
|
+
regex = '''npm_[A-Za-z0-9]{36}'''
|
|
80
|
+
tags = ["key", "npm"]
|
|
81
|
+
|
|
82
|
+
# ── Repository-specific allowlists ──────────────────────────────────
|
|
83
|
+
|
|
84
|
+
# (Add path- or regex-based overrides here as needed.)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ZoRDoK
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PLAN.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Plan: Idle Indicator Extension for Pi
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Создать расширение для Pi agent harness (`pi-idle.ts`), которое ставит зелёную текстовую галочку слева от текущего заголовка терминала, когда сессия завершена и ожидает ввод пользователя. Когда пользователь начинает работать (отправляет промпт), галочка заменяется на текстовый квадратный спиннер. Когда агент заканчивает ответ — снова зелёная галочка.
|
|
6
|
+
|
|
7
|
+
## Approach
|
|
8
|
+
|
|
9
|
+
Одним файлом TypeScript-расширения `pi-idle.ts`, которое:
|
|
10
|
+
|
|
11
|
+
- Подписывается на события `session_start`, `input` и `agent_end`
|
|
12
|
+
- Использует `ctx.ui.setTitle()` для обновления заголовка терминала
|
|
13
|
+
- **Не заменяет** заголовок целиком, а ставит индикатор **слева** от текущего текста заголовка
|
|
14
|
+
- На `session_start` / `agent_end` → ставит зелёную галочку `✓` слева от заголовка
|
|
15
|
+
- На `input` → заменяет галочку на минималистичный текстовый спиннер из одного символа (например `▌ ▋ ▊ ▉ ▊ ▋` — вращающийся квадрат) с цветом, отражающим процент использованного контекста
|
|
16
|
+
- После `agent_end` → снова зелёная галочка
|
|
17
|
+
|
|
18
|
+
В репозитории:
|
|
19
|
+
- Расширение в корне (`pi-idle.ts`)
|
|
20
|
+
- Конфиг `.gitleaks.toml` для gitleaks (инструмент уже установлен в системе)
|
|
21
|
+
- Интеграционный тест на vitest
|
|
22
|
+
|
|
23
|
+
## Files to Create
|
|
24
|
+
|
|
25
|
+
| File | Purpose |
|
|
26
|
+
|------|---------|
|
|
27
|
+
| `pi-idle.ts` | Расширение Pi — единственный файл расширения |
|
|
28
|
+
| `.gitleaks.toml` | Конфигурация Gitleaks для репозитория |
|
|
29
|
+
| `vitest.config.ts` | Конфигурация vitest |
|
|
30
|
+
| `test.ts` | Интеграционный тест |
|
|
31
|
+
| `package.json` | Для зависимостей vitest |
|
|
32
|
+
|
|
33
|
+
## Reuse
|
|
34
|
+
|
|
35
|
+
- Паттерн из `examples/extensions/titlebar-spinner.ts` — использует `ctx.ui.setTitle()` для изменения заголовка терминала
|
|
36
|
+
- Паттерн анимированного спиннера из `examples/extensions/titlebar-spinner.ts` — использует `ctx.ui.setTitle()` и `setInterval`
|
|
37
|
+
- `theme.fg("success", "✓")` из `status-line.ts` для зелёной галочки (ANSI-цвет в заголовке терминала)
|
|
38
|
+
- Типы: `ExtensionAPI`, `ExtensionContext` из `@earendil-works/pi-coding-agent` (доступны автоматически, без установки)
|
|
39
|
+
- События: `session_start`, `input` (event.source === "interactive"), `agent_end`
|
|
40
|
+
|
|
41
|
+
## Steps
|
|
42
|
+
|
|
43
|
+
- [ ] Step 1: Создать `pi-idle.ts` — само расширение
|
|
44
|
+
- Экспортирует `default function (pi: ExtensionAPI)`
|
|
45
|
+
- Функция формирования заголовка: читает базовый заголовок (через `process.title` или формирует как `π - {cwd}`), **префиксит** индикатор слева, а не заменяет заголовок
|
|
46
|
+
- Зелёная галочка: `theme.fg("success", "✓")`
|
|
47
|
+
- Квадратный спиннер: минималистичный набор из одного символа, вращающегося по кадрам: `▘ ▝ ▗ ▖` или `▌ ▋ ▊ ▉ ▊ ▋` (через `setInterval`, как в titlebar-spinner.ts)
|
|
48
|
+
- Цвет спиннера зависит от `ctx.getContextUsage()?.percent`:
|
|
49
|
+
- `< 20%` → `theme.fg("success", ...)` (зелёный)
|
|
50
|
+
- `20-39%` → `theme.fg("success", ...)` (зелёно-жёлтый — используется зелёный, т.к. отдельного цвета нет)
|
|
51
|
+
- `40-49%` → `theme.fg("warning", ...)` (жёлтый)
|
|
52
|
+
- `50-59%` → `theme.fg("warning", ...)` (оранжевый — используется warning)
|
|
53
|
+
- `≥ 60%` → `theme.fg("error", ...)` (красный)
|
|
54
|
+
- `≥ 90%` → добавляется `(!)` справа от спиннера
|
|
55
|
+
- На `session_start` → `ctx.ui.setTitle(theme.fg("success", "✓") + " " + baseTitle)`
|
|
56
|
+
- На `input` (source === "interactive") → запустить квадратный спиннер
|
|
57
|
+
- На `agent_end` → остановить спиннер, показать зелёную галочку
|
|
58
|
+
- На `session_shutdown` → остановить спиннер, очистить
|
|
59
|
+
|
|
60
|
+
- [ ] Step 2: Создать `.gitleaks.toml`
|
|
61
|
+
- Базовая конфигурация, разрешающая стандартные паттерны для Node/TS проектов
|
|
62
|
+
- Исключения для тестовых данных, если нужны
|
|
63
|
+
|
|
64
|
+
- [ ] Step 3: Создать `package.json`
|
|
65
|
+
- `type: "module"`
|
|
66
|
+
- `devDependencies`: только `vitest`
|
|
67
|
+
- Никаких зависимостей от `@earendil-works/pi-coding-agent` или `typebox` — они доступны автоматически через рантайм pi
|
|
68
|
+
|
|
69
|
+
- [ ] Step 4: Создать `vitest.config.ts`
|
|
70
|
+
- Базовая конфигурация для vitest
|
|
71
|
+
|
|
72
|
+
- [ ] Step 5: Создать `test.ts`
|
|
73
|
+
- Интеграционный тест на vitest, который:
|
|
74
|
+
- Спавнит `pi -e ./pi-idle.ts -p "test"` и проверяет exit code 0
|
|
75
|
+
- Импортирует расширение и тестирует напрямую с mock `ExtensionAPI`
|
|
76
|
+
- Проверяет, что `setTitle` вызывается с зелёной галочкой на `agent_end` и со спиннером на `input`
|
|
77
|
+
|
|
78
|
+
- [ ] Step 6: Проверить gitleaks
|
|
79
|
+
- `gitleaks detect --source . --verbose`
|
|
80
|
+
|
|
81
|
+
## Verification
|
|
82
|
+
|
|
83
|
+
1. Запуск через `pi -e ./pi-idle.ts`
|
|
84
|
+
2. Визуальная проверка:
|
|
85
|
+
- При старте pi в заголовке терминала видно `✓ π - project-name` (галочка зелёного цвета)
|
|
86
|
+
- После отправки промпта галочка заменяется на анимированный квадратный спиннер
|
|
87
|
+
- После завершения ответа агента снова зелёная галочка
|
|
88
|
+
3. `npx vitest run` — тесты проходят
|
|
89
|
+
4. `gitleaks detect --source .` — без утечек
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# pi-idle
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi-coding-agent) extension that puts a **✓ checkmark** in your terminal title when the agent is idle, and a **◰◳◲◱ spinner** while it's working — so you can tell at a glance whether pi is done or still thinking.
|
|
4
|
+
|
|
5
|
+
## Title format
|
|
6
|
+
|
|
7
|
+
| State | Title |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Idle (≤ 50 % context) | `✓ π - session - project` |
|
|
10
|
+
| Idle (> 50 % context) | `✓ [63%] π - session - project` |
|
|
11
|
+
| Idle (≥ 90 % context) | `✓ ![95%]! π - session - project` |
|
|
12
|
+
| Working | `◰ π - session - project` |
|
|
13
|
+
| Shutdown | `π - session - project` |
|
|
14
|
+
|
|
15
|
+
- The context-usage percentage is shown **only** beside the checkmark — never during the spinner. It's captured once when the agent finishes, not updated live.
|
|
16
|
+
- `≤ 50 %` → percentage hidden; `> 50 %` → `[N%]`; `≥ 90 %` → `![N%]!` (high-usage warning).
|
|
17
|
+
- ANSI colours don't work inside terminal-title OSC sequences, so everything is plain text.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
# One-off run
|
|
23
|
+
pi -e ./pi-idle.ts
|
|
24
|
+
|
|
25
|
+
# Auto-discovery: drop into your extensions directory
|
|
26
|
+
cp pi-idle.ts ~/.pi/agent/extensions/pi-idle.ts
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Test
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm test # single run
|
|
33
|
+
npm run test:watch # watch mode
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
CI runs on every push / PR to `master` via GitHub Actions.
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
[MIT](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-idle",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pi extension: shows ✓ in terminal title when idle, spinner (◰◳◲◱) while working, with context-usage percentage beside the checkmark",
|
|
5
|
+
"keywords": ["idle", "done", "spinner", "pi-extension"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"test:watch": "vitest"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"vitest": "^3.2.4"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/pi-idle.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Idle Extension
|
|
3
|
+
*
|
|
4
|
+
* Shows a green checkmark (✓) in the **terminal title** when pi is idle
|
|
5
|
+
* (session finished, waiting for user input). When the user submits
|
|
6
|
+
* a prompt, the checkmark becomes a "square clock" spinner (◰◳◲◱).
|
|
7
|
+
*
|
|
8
|
+
* Context-usage percentage appears beside the checkmark (idle state) in the title:
|
|
9
|
+
* ≤ 50% → not shown
|
|
10
|
+
* > 50% → [N%] (e.g. ✓ [63%] π - project)
|
|
11
|
+
* ≥ 90% → ![N%]! (e.g. ✓ ![95%]! π - project)
|
|
12
|
+
* The percentage is captured once when the checkmark is shown; the spinner never displays it.
|
|
13
|
+
*
|
|
14
|
+
* ANSI colours are not supported inside terminal-title OSC sequences,
|
|
15
|
+
* so no colour is attempted there.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* pi -e ./pi-idle.ts
|
|
19
|
+
* # Or place in ~/.pi/agent/extensions/pi-idle.ts for auto-discovery
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
|
|
25
|
+
// ── Spinner frames ──────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** "Square clock" — each frame fills a different quadrant of a square. */
|
|
28
|
+
const SPINNER_FRAMES = ["◰", "◳", "◲", "◱"];
|
|
29
|
+
|
|
30
|
+
// ── Base title (mirrors pi's default format) ────────────────────
|
|
31
|
+
|
|
32
|
+
function getBaseTitle(pi: ExtensionAPI): string {
|
|
33
|
+
const cwd = path.basename(process.cwd());
|
|
34
|
+
const session = pi.getSessionName();
|
|
35
|
+
return session ? `π - ${session} - ${cwd}` : `π - ${cwd}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Context helpers ─────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a context indicator for the title.
|
|
42
|
+
* ≤ 50% → "" (hidden)
|
|
43
|
+
* > 50% → "[N%]"
|
|
44
|
+
* ≥ 90% → "![N%]!"
|
|
45
|
+
*/
|
|
46
|
+
function getContextIndicator(ctx: ExtensionContext): string {
|
|
47
|
+
const usage = ctx.getContextUsage();
|
|
48
|
+
const percent = usage?.percent ?? null;
|
|
49
|
+
if (percent === null || percent <= 50) return "";
|
|
50
|
+
const pct = Math.round(percent);
|
|
51
|
+
if (percent >= 90) return `![${pct}%]!`;
|
|
52
|
+
return `[${pct}%]`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Extension entry point ───────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export default function (pi: ExtensionAPI) {
|
|
58
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
59
|
+
let frameIndex = 0;
|
|
60
|
+
|
|
61
|
+
// ── Internal helpers ───────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function stopSpinner() {
|
|
64
|
+
if (timer) {
|
|
65
|
+
clearInterval(timer);
|
|
66
|
+
timer = null;
|
|
67
|
+
}
|
|
68
|
+
frameIndex = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Write the idle title (plain text — colours don't work in OSC titles). */
|
|
72
|
+
function showDone(ctx: ExtensionContext) {
|
|
73
|
+
stopSpinner();
|
|
74
|
+
const baseTitle = getBaseTitle(pi);
|
|
75
|
+
const indicator = getContextIndicator(ctx);
|
|
76
|
+
const spacer = indicator ? " " : "";
|
|
77
|
+
ctx.ui.setTitle(`✓${spacer}${indicator} ${baseTitle}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Start the spinner in the title. No context percentage — it only appears with the checkmark. */
|
|
81
|
+
function startSpinner(ctx: ExtensionContext) {
|
|
82
|
+
stopSpinner();
|
|
83
|
+
|
|
84
|
+
timer = setInterval(() => {
|
|
85
|
+
const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length];
|
|
86
|
+
const baseTitle = getBaseTitle(pi);
|
|
87
|
+
ctx.ui.setTitle(`${frame} ${baseTitle}`);
|
|
88
|
+
frameIndex++;
|
|
89
|
+
}, 120);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Lifecycle hooks ────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
95
|
+
showDone(ctx);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("input", async (event, ctx) => {
|
|
99
|
+
if (event.source === "interactive") {
|
|
100
|
+
startSpinner(ctx);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
105
|
+
showDone(ctx);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
109
|
+
stopSpinner();
|
|
110
|
+
ctx.ui.setTitle(getBaseTitle(pi));
|
|
111
|
+
})
|
|
112
|
+
}
|
package/test.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for pi-idle.ts extension.
|
|
3
|
+
*
|
|
4
|
+
* Tests:
|
|
5
|
+
* 1. Module loads and exports a default function
|
|
6
|
+
* 2. All 4 lifecycle handlers are registered
|
|
7
|
+
* 3. `session_start` → plain checkmark in title
|
|
8
|
+
* 4. `input` (interactive) → spinner starts in title
|
|
9
|
+
* 5. `input` (non-interactive) → no spinner
|
|
10
|
+
* 6. `agent_end` → checkmark restored, spinner stopped
|
|
11
|
+
* 7. `session_shutdown` → plain base title
|
|
12
|
+
* 8. Context ≤50% → no indicator in title
|
|
13
|
+
* 9. Context >50% → [N%] in title
|
|
14
|
+
* 10. Context ≥90% → ![N%]! in title
|
|
15
|
+
* 11. Context null → no indicator
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function createMockPi(): ExtensionAPI & { _handlers: Map<string, Function> } {
|
|
24
|
+
const handlers = new Map<string, Function>();
|
|
25
|
+
return {
|
|
26
|
+
on: vi.fn((event: string, handler: Function) => {
|
|
27
|
+
handlers.set(event, handler);
|
|
28
|
+
}) as unknown as ExtensionAPI["on"],
|
|
29
|
+
getSessionName: vi.fn(() => null) as unknown as ExtensionAPI["getSessionName"],
|
|
30
|
+
_handlers: handlers,
|
|
31
|
+
} as unknown as ExtensionAPI & { _handlers: Map<string, Function> };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createMockCtx(overrides?: Partial<ExtensionContext>): ExtensionContext {
|
|
35
|
+
const theme = {
|
|
36
|
+
fg: vi.fn((_color: string, text: string) => `«${_color}:${text}»`),
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
ui: {
|
|
40
|
+
theme,
|
|
41
|
+
setTitle: vi.fn(),
|
|
42
|
+
setStatus: vi.fn(),
|
|
43
|
+
} as unknown as ExtensionContext["ui"],
|
|
44
|
+
getContextUsage: vi.fn(() => ({ percent: 25, tokens: 25000, contextWindow: 100000 })),
|
|
45
|
+
hasUI: true,
|
|
46
|
+
...overrides,
|
|
47
|
+
} as ExtensionContext;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Module-level tests ───────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe("pi-idle.ts module", () => {
|
|
53
|
+
it("loads without errors", async () => {
|
|
54
|
+
const mod = await import("./pi-idle.ts");
|
|
55
|
+
expect(typeof mod.default).toBe("function");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("registers all four lifecycle handlers", async () => {
|
|
59
|
+
const mockPi = createMockPi();
|
|
60
|
+
const mod = await import("./pi-idle.ts");
|
|
61
|
+
mod.default(mockPi as unknown as ExtensionAPI);
|
|
62
|
+
expect(mockPi.on).toHaveBeenCalled();
|
|
63
|
+
const events = new Set(
|
|
64
|
+
(mockPi.on as ReturnType<typeof vi.fn>).mock.calls.map((c: string[]) => c[0]),
|
|
65
|
+
);
|
|
66
|
+
expect(events.has("session_start")).toBe(true);
|
|
67
|
+
expect(events.has("input")).toBe(true);
|
|
68
|
+
expect(events.has("agent_end")).toBe(true);
|
|
69
|
+
expect(events.has("session_shutdown")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── Handler behaviour ────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("extension handlers", () => {
|
|
76
|
+
let mockPi: ReturnType<typeof createMockPi>;
|
|
77
|
+
|
|
78
|
+
beforeEach(async () => {
|
|
79
|
+
mockPi = createMockPi();
|
|
80
|
+
const mod = await import("./pi-idle.ts");
|
|
81
|
+
mod.default(mockPi as unknown as ExtensionAPI);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("session_start: ≤50% context → no indicator in title", async () => {
|
|
85
|
+
const ctx = createMockCtx(); // 25% ≤ 50%
|
|
86
|
+
const handler = mockPi._handlers.get("session_start")!;
|
|
87
|
+
await handler({ reason: "startup" }, ctx);
|
|
88
|
+
|
|
89
|
+
expect(ctx.ui.setTitle).toHaveBeenCalledWith("✓ π - pi-idle");
|
|
90
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("input (interactive) starts spinner in title", async () => {
|
|
94
|
+
const ctx = createMockCtx(); // 25% ≤ 50% → no indicator
|
|
95
|
+
const handler = mockPi._handlers.get("input")!;
|
|
96
|
+
await handler({ source: "interactive", text: "hello" }, ctx);
|
|
97
|
+
|
|
98
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
99
|
+
|
|
100
|
+
expect(ctx.ui.setTitle).toHaveBeenCalled();
|
|
101
|
+
const firstCall = (ctx.ui.setTitle as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
102
|
+
// Just spinner + base title, no indicator (25% ≤ 50%)
|
|
103
|
+
expect(firstCall).toMatch(/^[◰◳◲◱] π - pi-idle$/);
|
|
104
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("input (non-interactive) does NOT start spinner", async () => {
|
|
108
|
+
const ctx = createMockCtx();
|
|
109
|
+
const handler = mockPi._handlers.get("input")!;
|
|
110
|
+
await handler({ source: "extension", text: "internal" }, ctx);
|
|
111
|
+
|
|
112
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
113
|
+
expect(ctx.ui.setTitle).not.toHaveBeenCalled();
|
|
114
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("agent_end restores checkmark in title", async () => {
|
|
118
|
+
const ctx = createMockCtx(); // 25% ≤ 50%
|
|
119
|
+
const handler = mockPi._handlers.get("agent_end")!;
|
|
120
|
+
await handler({ messages: [] }, ctx);
|
|
121
|
+
|
|
122
|
+
expect(ctx.ui.setTitle).toHaveBeenCalledWith("✓ π - pi-idle");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("session_shutdown sets plain base title", async () => {
|
|
126
|
+
const ctx = createMockCtx();
|
|
127
|
+
const handler = mockPi._handlers.get("session_shutdown")!;
|
|
128
|
+
await handler({ reason: "quit" }, ctx);
|
|
129
|
+
|
|
130
|
+
expect(ctx.ui.setTitle).toHaveBeenCalledWith("π - pi-idle");
|
|
131
|
+
expect(ctx.ui.setStatus).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── Context indicator ────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe("context indicator", () => {
|
|
138
|
+
it("≤50%: no indicator in title", async () => {
|
|
139
|
+
const ctx = createMockCtx({
|
|
140
|
+
getContextUsage: () => ({ percent: 50, tokens: 50000, contextWindow: 100000 }),
|
|
141
|
+
});
|
|
142
|
+
const mockPi = createMockPi();
|
|
143
|
+
const mod = await import("./pi-idle.ts");
|
|
144
|
+
mod.default(mockPi as unknown as ExtensionAPI);
|
|
145
|
+
|
|
146
|
+
const handler = mockPi._handlers.get("session_start")!;
|
|
147
|
+
await handler({ reason: "startup" }, ctx);
|
|
148
|
+
expect(ctx.ui.setTitle).toHaveBeenCalledWith("✓ π - pi-idle");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it(">50% and <90%: shows [N%] in title", async () => {
|
|
152
|
+
const ctx = createMockCtx({
|
|
153
|
+
getContextUsage: () => ({ percent: 63.7, tokens: 63700, contextWindow: 100000 }),
|
|
154
|
+
});
|
|
155
|
+
const mockPi = createMockPi();
|
|
156
|
+
const mod = await import("./pi-idle.ts");
|
|
157
|
+
mod.default(mockPi as unknown as ExtensionAPI);
|
|
158
|
+
|
|
159
|
+
const handler = mockPi._handlers.get("session_start")!;
|
|
160
|
+
await handler({ reason: "startup" }, ctx);
|
|
161
|
+
expect(ctx.ui.setTitle).toHaveBeenCalledWith("✓ [64%] π - pi-idle");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("≥90%: shows ![N%]! in title", async () => {
|
|
165
|
+
const ctx = createMockCtx({
|
|
166
|
+
getContextUsage: () => ({ percent: 95, tokens: 95000, contextWindow: 100000 }),
|
|
167
|
+
});
|
|
168
|
+
const mockPi = createMockPi();
|
|
169
|
+
const mod = await import("./pi-idle.ts");
|
|
170
|
+
mod.default(mockPi as unknown as ExtensionAPI);
|
|
171
|
+
|
|
172
|
+
const handler = mockPi._handlers.get("session_start")!;
|
|
173
|
+
await handler({ reason: "startup" }, ctx);
|
|
174
|
+
expect(ctx.ui.setTitle).toHaveBeenCalledWith("✓ ![95%]! π - pi-idle");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("context null: no indicator in title", async () => {
|
|
178
|
+
const ctx = createMockCtx({
|
|
179
|
+
getContextUsage: () => null,
|
|
180
|
+
});
|
|
181
|
+
const mockPi = createMockPi();
|
|
182
|
+
const mod = await import("./pi-idle.ts");
|
|
183
|
+
mod.default(mockPi as unknown as ExtensionAPI);
|
|
184
|
+
|
|
185
|
+
const handler = mockPi._handlers.get("session_start")!;
|
|
186
|
+
await handler({ reason: "startup" }, ctx);
|
|
187
|
+
expect(ctx.ui.setTitle).toHaveBeenCalledWith("✓ π - pi-idle");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("spinner never includes context indicator, even at ≥90%", async () => {
|
|
191
|
+
const ctx = createMockCtx({
|
|
192
|
+
getContextUsage: () => ({ percent: 91, tokens: 91000, contextWindow: 100000 }),
|
|
193
|
+
});
|
|
194
|
+
const mockPi = createMockPi();
|
|
195
|
+
const mod = await import("./pi-idle.ts");
|
|
196
|
+
mod.default(mockPi as unknown as ExtensionAPI);
|
|
197
|
+
|
|
198
|
+
const handler = mockPi._handlers.get("input")!;
|
|
199
|
+
await handler({ source: "interactive", text: "test" }, ctx);
|
|
200
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
201
|
+
|
|
202
|
+
const titleCalls = (ctx.ui.setTitle as ReturnType<typeof vi.fn>).mock.calls;
|
|
203
|
+
const allPlain = titleCalls.every((c: unknown[]) =>
|
|
204
|
+
/^[◰◳◲◱] π - pi-idle$/.test(c[0] as string),
|
|
205
|
+
);
|
|
206
|
+
expect(allPlain).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
});
|