pi-pignon 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -411
- package/docs/CONFIGURATION.md +234 -0
- package/docs/DESIGN.md +49 -0
- package/docs/PLAN-command-output.md +161 -0
- package/docs/PLAN-deciders.md +327 -0
- package/docs/assets/pignon.svg +14 -0
- package/package.json +2 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
pignon reads `~/.pi/agent/pignon.json` (or the path in `PIGNON_CONFIG`).
|
|
4
|
+
Every key is optional: with no file, it uses the built-in defaults shown below.
|
|
5
|
+
Add the `$schema` line for autocompletion and inline validation in your editor.
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"$schema": "https://raw.githubusercontent.com/siiick/pi-pignon/main/schema/config.schema.json",
|
|
10
|
+
"version": 2
|
|
11
|
+
}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Run `/pignon config` to see the resolved table currently in use.
|
|
15
|
+
|
|
16
|
+
## Environment variables
|
|
17
|
+
|
|
18
|
+
| Variable | Default | Description |
|
|
19
|
+
|----------|---------|-------------|
|
|
20
|
+
| `PIGNON_CONFIG` | `<Pi config dir>/pignon.json` | Path of the optional config file |
|
|
21
|
+
| `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi's config directory; pignon keeps its config and exports there |
|
|
22
|
+
| `TYPESAFE_API_KEY` | *(unset)* | Jev API key (another variable can be named with `apiKeyEnv`) |
|
|
23
|
+
| `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | Jev API root, when `baseURL` is not set |
|
|
24
|
+
| `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | Jev model, when `model` is not set |
|
|
25
|
+
| `LAYA_ROUTER_CONFIG` | `~/.pi/agent/laya-router.json` | **Legacy:** read only when there is no pignon config |
|
|
26
|
+
|
|
27
|
+
laya-serve reads its own `LAYA_*` variables (`LAYA_HOST`, `LAYA_PORT`, `LAYA_MODELS`, `LAYA_API_KEY`, …) when it starts; see [Laya's documentation](https://pypi.org/project/laya/). The variables of the experimental worker are listed in its [section below](#experimental-pignons-mlx-worker).
|
|
28
|
+
|
|
29
|
+
## Deciders
|
|
30
|
+
|
|
31
|
+
`deciders` picks the decision model. `/pignon init` writes it for you. Without it, pignon uses the experimental worker when installed, else Jev when `TYPESAFE_API_KEY` is set; it does not look for laya-serve on its own.
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"deciders": [
|
|
36
|
+
{ "type": "laya-serve" }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Type | Key | Default | Meaning |
|
|
42
|
+
|------|-----|---------|---------|
|
|
43
|
+
| `laya-serve` | `url` | `http://127.0.0.1:8000` | Where laya-serve listens. On another machine, prompts leave yours and cards show ☁ |
|
|
44
|
+
| | `model` | *(server's choice)* | Laya checkpoint: `english`, `multilingual` or `typed-decisions`. By default the server picks one from the prompt's language |
|
|
45
|
+
| | `apiKeyEnv` | *(none)* | Environment variable holding the server's key, when you started it with `LAYA_API_KEY`. Your TypeSafe key is never sent to laya-serve |
|
|
46
|
+
| | `timeoutMs` | `1500` | Timeout for one decision |
|
|
47
|
+
| `laya-local` | | | [Experimental worker](#experimental-pignons-mlx-worker), see its section |
|
|
48
|
+
| `jev` | `model` | `jev-latest` | Jev version to pin. Confidences are calibrated per version, so pinning keeps your thresholds valid |
|
|
49
|
+
| | `apiKeyEnv` | `TYPESAFE_API_KEY` | Environment variable holding the key. Keys are never read from the config file |
|
|
50
|
+
| | `baseURL` | TypeSafe | `https://openrouter.ai/api` to go through OpenRouter (with `"apiKeyEnv": "OPENROUTER_API_KEY"`) |
|
|
51
|
+
| | `timeoutMs` | `1500` | Timeout for one decision |
|
|
52
|
+
| | `maxRetries` | `0` | Retries after a failed call; each gets the full timeout |
|
|
53
|
+
|
|
54
|
+
### Using several deciders
|
|
55
|
+
|
|
56
|
+
List more than one and `strategy` says how they work together:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"deciders": [{ "type": "laya-serve" }, { "type": "jev" }],
|
|
61
|
+
"strategy": { "mode": "sequential", "escalateBelow": 0.75 }
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Key | Default | Meaning |
|
|
66
|
+
|-----|---------|---------|
|
|
67
|
+
| `mode` | `sequential` | `sequential`: ask the deciders in order; the next one is asked only when the previous one is not ready, fails (e.g. laya-serve not running), or is less confident than `escalateBelow`. Remote deciders are only called when needed. `parallel`: ask all of them at once |
|
|
68
|
+
| `escalateBelow` | `0.75` | Sequential: tier confidence under which the next decider is asked. The most confident answer wins |
|
|
69
|
+
| `pick` | `most-confident` | Parallel: route on the most confident answer, or `first`: on the first decider in the list that answered, the others being only recorded |
|
|
70
|
+
| `budgetMs` | `3000` | Wall-time limit for one decision, all deciders included |
|
|
71
|
+
|
|
72
|
+
**Benchmark Laya against Jev** without changing how you route: keep Laya in charge and record Jev's answers next to it, then compare.
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"deciders": [{ "type": "laya-serve" }, { "type": "jev" }],
|
|
77
|
+
"strategy": { "mode": "parallel", "pick": "first" }
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`/pignon-stats compare` shows tier and exploration agreement, a confusion matrix, mean confidence, latency, failures and cost per decider.
|
|
82
|
+
`/pignon-stats export` writes every decision (with each decider's answer) as JSON lines for your own analysis. In parallel mode, every routed prompt is sent to Jev.
|
|
83
|
+
|
|
84
|
+
Laya's confidence is low (0.05–0.27 on typical prompts: the checkpoint's temperatures are uncalibrated), so with the default `escalateBelow` of 0.75, sequential mode asks Jev on almost every prompt. Lower `escalateBelow`, or set `"confidenceSource": "top-probability"` (see [Other settings](#other-settings)).
|
|
85
|
+
|
|
86
|
+
### Start laya-serve at login (macOS)
|
|
87
|
+
|
|
88
|
+
A launchd agent keeps laya-serve running in the background and restarts it if it stops. Save this as `~/Library/LaunchAgents/local.laya-serve.plist`, replacing `/Users/you/.local/bin/laya-serve` with the output of `which laya-serve`:
|
|
89
|
+
|
|
90
|
+
```xml
|
|
91
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
92
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
93
|
+
<plist version="1.0">
|
|
94
|
+
<dict>
|
|
95
|
+
<key>Label</key><string>local.laya-serve</string>
|
|
96
|
+
<key>ProgramArguments</key>
|
|
97
|
+
<array><string>/Users/you/.local/bin/laya-serve</string></array>
|
|
98
|
+
<key>EnvironmentVariables</key>
|
|
99
|
+
<dict>
|
|
100
|
+
<key>LAYA_HOST</key><string>127.0.0.1</string>
|
|
101
|
+
<key>LAYA_MODELS</key><string>english,multilingual</string>
|
|
102
|
+
</dict>
|
|
103
|
+
<key>RunAtLoad</key><true/>
|
|
104
|
+
<key>KeepAlive</key><true/>
|
|
105
|
+
<key>StandardErrorPath</key><string>/tmp/laya-serve.log</string>
|
|
106
|
+
</dict>
|
|
107
|
+
</plist>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.laya-serve.plist # start now and at login
|
|
112
|
+
launchctl bootout gui/$(id -u)/local.laya-serve # stop and disable
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Experimental: pignon's MLX worker
|
|
116
|
+
|
|
117
|
+
pignon also has its own Laya worker (`worker/` in this repository), built on [laya-mlx](https://github.com/mizorewww/laya-mlx). It is a little faster than laya-serve on Apple Silicon (~61 ms against ~75 ms per decision, with the same answers), smaller to install, and needs no server: pignon starts it with the session and stops it afterwards. It is **experimental and not published**, and its interface may change; prefer laya-serve.
|
|
118
|
+
|
|
119
|
+
It needs an Apple Silicon Mac and a clone of this repository:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
git clone https://github.com/siiick/pi-pignon
|
|
123
|
+
uv tool install ./pi-pignon/worker # puts pignon-laya on PATH
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
then `"deciders": [{ "type": "laya-local" }]`. pignon starts the worker with the first of:
|
|
127
|
+
|
|
128
|
+
1. `command` in the `laya-local` decider, e.g. `["uv", "run", "--project", "/path/to/pi-pignon/worker", "pignon-laya"]`;
|
|
129
|
+
2. `LAYA_PYTHON`, running `laya_worker.py` from `LAYA_WORKER_DIR`;
|
|
130
|
+
3. a source checkout's `worker/.venv` (after `uv sync`), when pignon itself runs from that checkout;
|
|
131
|
+
4. `pignon-laya` on `PATH`.
|
|
132
|
+
|
|
133
|
+
`/pignon doctor` says which one is used. The worker reports its protocol version when it starts; pignon refuses a worker it cannot talk to and says which side to update. Its settings:
|
|
134
|
+
|
|
135
|
+
| Key or variable | Default | Meaning |
|
|
136
|
+
|-----------------|---------|---------|
|
|
137
|
+
| `timeoutMs` (decider) | `thresholds.layaTimeoutMs` | Timeout for one decision |
|
|
138
|
+
| `command` (decider) | *(found automatically)* | Command that starts the worker |
|
|
139
|
+
| `LAYA_MODEL` | `aac6fef/laya-mlx` | Checkpoint (e.g. `aac6fef/laya-multilingual-mlx`); fixed for the life of the worker |
|
|
140
|
+
| `LAYA_MODEL_REVISION` | pinned commit for the default model, latest for others | Hugging Face revision to load; empty string means latest |
|
|
141
|
+
| `LAYA_PYTHON` | *(unset)* | Run `laya_worker.py` with this interpreter |
|
|
142
|
+
| `LAYA_WORKER_DIR` | `<extension>/worker` | Directory containing `laya_worker.py` |
|
|
143
|
+
| `LAYA_WORKER_SCRIPT` | `<worker dir>/laya_worker.py` | Explicit worker script path |
|
|
144
|
+
| `LAYA_DTYPE` | `float16` | Model dtype (`float16` / `float32`) |
|
|
145
|
+
| `LAYA_DEVICE` | *(auto)* | Device (`gpu` / `cpu` / empty) |
|
|
146
|
+
| `LAYA_BATCH_SIZE` | `16` | Questions per forward pass |
|
|
147
|
+
|
|
148
|
+
## Swap a model
|
|
149
|
+
|
|
150
|
+
Tiers refer to models by name. The built-in names are `fast`, `balanced`, `reasoner` and `agent`; redefine one to change every tier that uses it:
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"$schema": "https://raw.githubusercontent.com/siiick/pi-pignon/main/schema/config.schema.json",
|
|
155
|
+
"version": 2,
|
|
156
|
+
"models": {
|
|
157
|
+
"reasoner": { "provider": "anthropic", "modelId": "claude-opus-5-5", "thinking": "high" }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`thinking` is one of `off`, `low`, `medium`, `high`, `xhigh`; Pi clamps it to what the model supports. Check model ids with `pi --list-models`.
|
|
163
|
+
|
|
164
|
+
## Presets
|
|
165
|
+
|
|
166
|
+
A preset fills the four built-in model names from one provider. Use it with `extends`, and override any name under `models`:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"extends": "anthropic",
|
|
171
|
+
"models": { "fast": { "provider": "anthropic", "modelId": "claude-haiku-4-5-20251001", "thinking": "off" } }
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
| Preset | `fast` | `balanced` | `reasoner` | `agent` |
|
|
176
|
+
|--------|--------|------------|------------|---------|
|
|
177
|
+
| `openrouter` (default) | deepseek-v4-flash-0731 · off | deepseek-v4.1-flash · low | glm-5.3 · high | hy4-preview · low |
|
|
178
|
+
| `anthropic` | claude-haiku-4-5 · off | claude-sonnet-5 · low | claude-opus-5-5 · high | claude-sonnet-5 · medium |
|
|
179
|
+
| `openai` | gpt-6-luna · off | gpt-5.6-terra · low | gpt-6-sol · high | gpt-5.3-codex · medium |
|
|
180
|
+
|
|
181
|
+
Presets are starting points, not recommendations: check prices and quality on your own work (`/pignon-stats`, shadow mode).
|
|
182
|
+
|
|
183
|
+
## Write your own tiers
|
|
184
|
+
|
|
185
|
+
`tiers` replaces the built-in list as a whole: 2 to 8 tiers, **easiest first** (position is rank, so moving down the list is a downgrade). Each tier has:
|
|
186
|
+
|
|
187
|
+
| Key | Meaning |
|
|
188
|
+
|-----|---------|
|
|
189
|
+
| `id` | Tier name shown on decision cards (lowercase, digits, `-`, `_`) |
|
|
190
|
+
| `criterion` | How to recognize a task of this tier. **This is the text the decision model reads**, so write it as a description of the task |
|
|
191
|
+
| `model` | Model for every task of the tier… |
|
|
192
|
+
| `direct` / `exploration` | …or one model for each form |
|
|
193
|
+
| `explorationAllowed` | `false` sends tasks that need exploration to the next tier up (default `true`) |
|
|
194
|
+
|
|
195
|
+
A model is a name from `models` or an inline `{ provider, modelId, thinking }`. See [`examples/pignon.json`](../examples/pignon.json) for a four-tier table.
|
|
196
|
+
|
|
197
|
+
When you change the wording of criteria or questions, also change `questions.version`: it is stored with each decision, so you can tell which wording your thresholds were calibrated on.
|
|
198
|
+
|
|
199
|
+
### Built-in table
|
|
200
|
+
|
|
201
|
+
| Tier | Direct | Exploration |
|
|
202
|
+
|------|--------|-------------|
|
|
203
|
+
| trivial | `fast`: `openrouter/deepseek/deepseek-v4-flash-0731` · off | → standard (`explorationAllowed: false`) |
|
|
204
|
+
| standard | `balanced`: `openrouter/deepseek/deepseek-v4.1-flash` · low | same |
|
|
205
|
+
| hard | `reasoner`: `openrouter/z-ai/glm-5.3` · high | `agent`: `openrouter/tencent/hy4-preview` · low |
|
|
206
|
+
|
|
207
|
+
## Other settings
|
|
208
|
+
|
|
209
|
+
| Key | Default | Meaning |
|
|
210
|
+
|-----|---------|---------|
|
|
211
|
+
| `questions.version` | `q1` | Label stored with each decision |
|
|
212
|
+
| `questions.tierInstructions` | *How much reasoning does solving this request demand…* | The tier question |
|
|
213
|
+
| `questions.explorationInstructions` | *Does answering require exploring the codebase…* | The exploration question |
|
|
214
|
+
| `questions.explorationCriteria` | `{ yes, no }` | What `yes` and `no` mean |
|
|
215
|
+
| `confidenceSource` | `reported` | `top-probability` routes on the chosen answer's probability instead of the model's confidence, for checkpoints whose confidence is uncalibrated |
|
|
216
|
+
|
|
217
|
+
| Threshold | Default | Meaning |
|
|
218
|
+
|-----------|---------|---------|
|
|
219
|
+
| `minConfidenceDowngrade` | `0.85` | Tier confidence needed to downgrade, or to move in from a model outside the table |
|
|
220
|
+
| `minConfidenceUpgrade` | `0.5` | Tier confidence needed to upgrade |
|
|
221
|
+
| `minConfidenceForm` | `0.6` | Confidence needed to call a task `direct` rather than `exploration` |
|
|
222
|
+
| `minPromptsBetweenSwitches` | `2` | Prompts to wait after a switch before the next downgrade or lateral switch |
|
|
223
|
+
| `maxPaybackRequests` | `3` | A downgrade must recoup its cache-miss cost within this many LLM requests |
|
|
224
|
+
| `assumedOutputTokensPerRequest` | `1000` | Output per request assumed when estimating what a downgrade saves |
|
|
225
|
+
| `cacheGuardTokens` | `60000` | Context size above which lateral switches (and downgrades, when prices are unknown) are refused |
|
|
226
|
+
| `layaTimeoutMs` | `2500` | Timeout for one decision of the experimental worker |
|
|
227
|
+
|
|
228
|
+
## Coming from laya-router
|
|
229
|
+
|
|
230
|
+
A `~/.pi/agent/laya-router.json` is still read when there is no `pignon.json`,
|
|
231
|
+
including its old `tiers: { hard: { direct: … } }` format. pignon warns at
|
|
232
|
+
session start; run `/pignon config migrate` to write the equivalent
|
|
233
|
+
`pignon.json` (a `pignon.json` in the old format is backed up to
|
|
234
|
+
`pignon.json.bak` first), then `/reload`.
|
package/docs/DESIGN.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Design notes
|
|
2
|
+
|
|
3
|
+
## Privacy
|
|
4
|
+
|
|
5
|
+
With `laya-serve` on this machine (`127.0.0.1` or `localhost`) or `laya-local`, prompts never leave it. With `jev`, or a laya-serve on another host, the first 4 000 characters of each routed prompt are sent over the network, and decision cards are marked ☁. The SDK's own logging is capped at `warn` and kept in `/pignon log`, so prompts are never logged, even with `TYPESAFE_LOG_LEVEL=debug`.
|
|
6
|
+
|
|
7
|
+
## Fail-open
|
|
8
|
+
|
|
9
|
+
If a decider cannot be reached or a decision fails, the decision is `null` and the extension keeps the current model. A laya-serve that is down refuses the connection at once, so the prompt waits a few milliseconds, not a timeout. The experimental worker loads its model in the background from `session_start`; prompts sent before it is ready are not routed (status shows `model loading — prompt not routed`) rather than held. It stays warm for the session, is reloaded in the background if it crashes, and is stopped on `session_shutdown`. A worker that is not ready within 5 minutes is killed.
|
|
10
|
+
|
|
11
|
+
## Switch cost
|
|
12
|
+
|
|
13
|
+
Switching models throws away the prompt cache: the first request on the new model reads the whole context at the uncached (or cache-write) price. Upgrades are quality-driven and only gated by confidence. A downgrade, or a move in from a model outside the table, must pay that premium back within `maxPaybackRequests` LLM requests out of what it saves per request (cheaper cache reads on the context plus cheaper output). Prices come from Pi's model registry; when either model has no price, the flat `cacheGuardTokens` limit applies instead. Lateral switches (direct ↔ exploration) are about fit rather than price and use the flat limit.
|
|
14
|
+
|
|
15
|
+
## Hysteresis
|
|
16
|
+
|
|
17
|
+
After the router switches, it waits `minPromptsBetweenSwitches` prompts before the next downgrade or lateral switch, so it does not flap between models. Upgrades are never delayed.
|
|
18
|
+
|
|
19
|
+
## Manual pin
|
|
20
|
+
|
|
21
|
+
If the user explicitly selects a model via `/model` or `Ctrl+P`, the extension steps back (`manualPin`) until `/pignon unpin`. The router's own switches also emit `model_select` (`source: "set"`) and are ignored.
|
|
22
|
+
|
|
23
|
+
## Unrouted models
|
|
24
|
+
|
|
25
|
+
If the current model is not in the routing table (matched on provider and model id), the router switches into the table only when tier confidence meets the downgrade threshold and the switch-cost check passes.
|
|
26
|
+
|
|
27
|
+
## Shared models
|
|
28
|
+
|
|
29
|
+
Cells mapped to the same provider, model and thinking level count as one; the router never re-selects the model already in use.
|
|
30
|
+
|
|
31
|
+
## Worker isolation
|
|
32
|
+
|
|
33
|
+
The worker gets an allowlisted environment (`PATH`, `HOME`, locale, proxies, CA bundles, `LAYA_*`, `HF_*`, `HUGGINGFACE_*`, `MLX_*`), not Pi's full environment with provider API keys. Its stderr is kept in memory (last 200 lines, see `/pignon log`) instead of being written over the TUI.
|
|
34
|
+
|
|
35
|
+
## Pinned model
|
|
36
|
+
|
|
37
|
+
The default checkpoint is pinned to the Hugging Face commit the router was calibrated on, so changes pushed to the Hub repo do not silently change routing. Bump `PINNED_REVISION` in `worker/laya_worker.py` deliberately, after re-checking decisions in shadow mode.
|
|
38
|
+
|
|
39
|
+
## Prompt privacy
|
|
40
|
+
|
|
41
|
+
Session log entries (`pignon-decision`) record a 16-hex-digit SHA-256 prefix and the length of each prompt, never its text.
|
|
42
|
+
|
|
43
|
+
## Bounded worker load
|
|
44
|
+
|
|
45
|
+
Only the first 4 000 characters of a prompt are sent (Laya reads about 320 tokens from the start anyway). Each request carries a deadline; the worker skips requests that expired while queued, so slow requests cannot pile up behind each other.
|
|
46
|
+
|
|
47
|
+
## Shadow mode default
|
|
48
|
+
|
|
49
|
+
New installs run in shadow mode so you can calibrate confidence thresholds on your own prompts before going live.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Plan (pignon): command output that is not truncated and does not persist
|
|
2
|
+
|
|
3
|
+
Status: implemented · 2026-09-23 (see *Changes from the proposal*)
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
`/pignon doctor` (and `config`, `log`, `stats`) render through `ctx.ui.setWidget(key, string[])`.
|
|
8
|
+
Two consequences, both bad UX:
|
|
9
|
+
|
|
10
|
+
1. **Truncated.** Pi keeps only the first 10 lines of a string-array widget and appends
|
|
11
|
+
`... (widget truncated)`. Doctor emits ~15–25 lines, so the model checks — the part the
|
|
12
|
+
user ran the command for — are silently dropped.
|
|
13
|
+
2. **Persists.** A widget is a persistent slot: it stays above the editor through every
|
|
14
|
+
following prompt until `setWidget(key, undefined)`, i.e. until `/pignon doctor clear`.
|
|
15
|
+
|
|
16
|
+
## Why it happens (verified in the Pi source)
|
|
17
|
+
|
|
18
|
+
`@earendil-works/pi-coding-agent` 0.87.1, `InteractiveMode` in
|
|
19
|
+
`dist/bundle/chunks/chunk-OJP47DM6.js`:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
if (Array.isArray(content)) {
|
|
23
|
+
let container = new Container;
|
|
24
|
+
for (let line of content.slice(0, _InteractiveMode.MAX_WIDGET_LINES))
|
|
25
|
+
container.addChild(new Text(line, 1, 0));
|
|
26
|
+
content.length > _InteractiveMode.MAX_WIDGET_LINES &&
|
|
27
|
+
container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
|
|
28
|
+
component = container;
|
|
29
|
+
} else component = content(this.ui, theme); // factory overload: no cap
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
with `static MAX_WIDGET_LINES = 10`.
|
|
33
|
+
|
|
34
|
+
The cap applies **only** to the `string[]` overload. The component-factory overload is
|
|
35
|
+
uncapped — `src/ui.ts` already relies on that for the `pignon-deciding` spinner.
|
|
36
|
+
|
|
37
|
+
Corroborated by a third-party extension (`itayinbarr/little-coder`,
|
|
38
|
+
`.pi/extensions/extensions-info/manifest.ts`):
|
|
39
|
+
|
|
40
|
+
> pi slices a string-array widget to MAX_WIDGET_LINES (10) and appends
|
|
41
|
+
> "... (widget truncated)", so anything past that is silently lost.
|
|
42
|
+
|
|
43
|
+
Persistence is by design, not a bug: `docs/tui.md` lists `ctx.ui.setWidget()` under
|
|
44
|
+
*"Persistent content near the editor"*. The fix is therefore to stop using a widget for
|
|
45
|
+
one-shot reports, not to tune the widget.
|
|
46
|
+
|
|
47
|
+
## What each command emits today
|
|
48
|
+
|
|
49
|
+
| command | lines | vs the 10-line cap |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| `doctor` | ~15–25: header, `config` + 1–2, `deciders` + 1–3, `models` + up to 8 | truncated |
|
|
52
|
+
| `log` | 30 (`LOG_WIDGET_LINES`) | truncated to 10 — worst case |
|
|
53
|
+
| `stats` | ~12+: header, bucket header, 3 tiers × 2 forms, summary | truncated |
|
|
54
|
+
| `config` | ~8: header, column header, N tiers, footer, hint | ok at 3 tiers, truncates at 4+ |
|
|
55
|
+
|
|
56
|
+
## Decision
|
|
57
|
+
|
|
58
|
+
Render one-shot reports in a **dismissible overlay** (`ctx.ui.custom({ overlay: true })`)
|
|
59
|
+
that scrolls itself: no line cap, scrollable, disposed on close so nothing persists.
|
|
60
|
+
Fallback to `notify` in RPC mode and to stderr in print/JSON mode.
|
|
61
|
+
|
|
62
|
+
Apply it to **all four** commands through one shared helper.
|
|
63
|
+
|
|
64
|
+
## Design
|
|
65
|
+
|
|
66
|
+
### `src/report.ts` (new)
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
export async function showReport(
|
|
70
|
+
ctx: ExtensionContext,
|
|
71
|
+
title: string,
|
|
72
|
+
lines: string[] | Promise<string[]>,
|
|
73
|
+
): Promise<void>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Three tiers, following `docs/extensions.md` (*"Guard terminal-only behavior with
|
|
77
|
+
`ctx.mode === "tui"` and use `ctx.hasUI` for interactions supported by interactive and RPC
|
|
78
|
+
clients"*):
|
|
79
|
+
|
|
80
|
+
| mode | behaviour |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `ctx.mode === "tui"` | `ctx.ui.custom(..., { overlay: true })` — `ReportOverlay` below |
|
|
83
|
+
| RPC (`ctx.hasUI`, not tui) | `ctx.ui.notify(lines.join("\n"), "info")` — RPC forwards `notify`, not custom components |
|
|
84
|
+
| print / JSON (`!ctx.hasUI`) | `process.stderr.write(lines.join("\n") + "\n")` — stdout stays parseable in JSON mode |
|
|
85
|
+
|
|
86
|
+
The `Promise<string[]>` overload matters for `doctor`, which can block ~15 s on the decider
|
|
87
|
+
probe: open the overlay immediately with `running checks…`, then `setLines()` →
|
|
88
|
+
`invalidate()` + `tui.requestRender()` when it resolves. The editor is never left looking
|
|
89
|
+
frozen.
|
|
90
|
+
|
|
91
|
+
### `ReportOverlay`
|
|
92
|
+
|
|
93
|
+
A `Component` (`render(width)` / `handleInput(data)` / `invalidate()` / `dispose()`):
|
|
94
|
+
|
|
95
|
+
- Keeps its own line offset and renders only the visible slice, framed by a
|
|
96
|
+
rounded border: title in the top rule; hint and `a–b/n` position in the bottom rule
|
|
97
|
+
- Body height: 70% of `tui.terminal.rows` (read on each render) minus the 2 border rows
|
|
98
|
+
- Overlay options: `{ width: <widest line + frame>, minWidth: 40, anchor: "center", margin: 1 }`;
|
|
99
|
+
`90%` while doctor's lines are still pending (Pi resolves overlay options once, at open)
|
|
100
|
+
- Keys: ↑↓ / `j` `k` (line), PgUp / PgDn / space (page), Home / End / `g` `G`,
|
|
101
|
+
`Esc` / `q` / `Enter` → `done()`
|
|
102
|
+
- Every line passes through `truncateToWidth(…, pad)` — see Risks
|
|
103
|
+
|
|
104
|
+
## Files
|
|
105
|
+
|
|
106
|
+
| file | change |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `src/report.ts` | **new** — `showReport()` + `ReportOverlay` |
|
|
109
|
+
| `src/extension.ts` | replace the 4 report `showWidget(...)` calls with `showReport(...)`; drop the `"(/pignon X clear to hide)"` trailers; raise `LOG_WIDGET_LINES` 30 → 200 (the overlay scrolls) |
|
|
110
|
+
| `src/config/describe.ts` | drop its internal `"(/pignon config clear to hide)"` line |
|
|
111
|
+
| `src/ui.ts` | unchanged — `showDeciding` is *meant* to persist, and already uses the uncapped factory overload |
|
|
112
|
+
| `src/onboarding.ts` | unchanged — already returns plain `string[]` |
|
|
113
|
+
| `tests/extension.test.ts`, `tests/extension-routing.test.ts` | add `custom` and `mode` to the mocks; update the `ctx.ui.setWidget` assertions |
|
|
114
|
+
| `tests/report.test.ts` | **new** — overlay line rendering and key handling (pure, no terminal) |
|
|
115
|
+
| `tests/helpers/fake-report.ts` | **new** — a `ctx.ui.custom` fake that keeps the overlay for assertions |
|
|
116
|
+
| `src/stats.ts`, `src/compare.ts` | drop their `"(/pignon-stats clear to hide)"` lines |
|
|
117
|
+
| `README.md`, `CHANGELOG.md` | the four commands no longer need `clear`; keep the subcommands as no-ops for back-compat |
|
|
118
|
+
|
|
119
|
+
## Steps
|
|
120
|
+
|
|
121
|
+
1. Write `src/report.ts`: `ReportOverlay` (Box + ScrollView + Text) and `showReport()`.
|
|
122
|
+
2. Rewire `doctor`, `config`, `log`, `stats` in `src/extension.ts`; remove the "clear to
|
|
123
|
+
hide" trailers.
|
|
124
|
+
3. Extend the test mocks with `custom` and `mode`, then update and add tests.
|
|
125
|
+
4. Update README and CHANGELOG.
|
|
126
|
+
|
|
127
|
+
## Risks
|
|
128
|
+
|
|
129
|
+
- **Width overflow.** pi-tui throws on over-wide lines (reported upstream as issue #48 by
|
|
130
|
+
`little-coder`). `truncateToWidth()` on every line is load-bearing, not cosmetic.
|
|
131
|
+
- **Modal.** `doctor` now needs `Esc` before the user can type again. Accepted: it is a
|
|
132
|
+
diagnostic, and it is the cost of nothing persisting.
|
|
133
|
+
- **Key collision.** `matchesKey(data, "q")` is safe here only because the overlay has no
|
|
134
|
+
text input. Any future search/filter field inside it must handle `q` as text first.
|
|
135
|
+
- **Back-compat.** `doctor clear` / `config clear` / `stats clear` / `log clear` stay
|
|
136
|
+
accepted and keep clearing the (now unused) widget key so a stale widget from an older
|
|
137
|
+
session cannot linger.
|
|
138
|
+
|
|
139
|
+
## Changes from the proposal
|
|
140
|
+
|
|
141
|
+
- **No `ScrollView`.** Pi composites overlays in `TUI.compositeOverlays()`
|
|
142
|
+
(`pi-tui/dist/tui.js`): it calls `component.render(width)` and slices the result to
|
|
143
|
+
`maxHeight`. The `[LAYOUT_NODE]` / `updateLayout` path runs only in
|
|
144
|
+
`renderLayoutFrame()` for the alt-screen root, never for overlays, and
|
|
145
|
+
`ScrollView.render()` returns all of its child's lines. A `ScrollView` in an overlay
|
|
146
|
+
would therefore never scroll, and `maxHeight: "70%"` would truncate it silently: the
|
|
147
|
+
same bug with a border around it. `ReportOverlay` scrolls itself instead.
|
|
148
|
+
- **Overlay options go under `overlayOptions`**, not at the top level of the `custom()`
|
|
149
|
+
options.
|
|
150
|
+
- **Width fits the content** instead of a fixed 80%, which truncated the config table's
|
|
151
|
+
last column at 100 columns.
|
|
152
|
+
- **Five call sites, not four**: `/pignon-stats compare` also used the widget. `stats.ts`
|
|
153
|
+
and `compare.ts` had their own "clear to hide" lines too.
|
|
154
|
+
- **Titles move into the border.** Each report's first line (`pignon doctor`,
|
|
155
|
+
`pignon config · <source>`, …) becomes the overlay title. The producers are unchanged,
|
|
156
|
+
so RPC and stderr output still start with it.
|
|
157
|
+
- `clear` subcommands are no longer offered in completion, but are still accepted.
|
|
158
|
+
- A rejected `lines` promise shows `✗ <message>` in the overlay instead of leaving
|
|
159
|
+
`running checks…` forever.
|
|
160
|
+
- Verified in Pi 0.87.1 through a pty: config and doctor render whole, doctor scrolls on a
|
|
161
|
+
14-row terminal, and Esc leaves nothing above the editor.
|