@walterra/pi-charts 0.0.1
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 +72 -0
- package/extensions/vega-chart/index.ts +306 -0
- package/extensions/vega-chart/vega-lite-reference.md +1597 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @walterra/pi-charts
|
|
2
|
+
|
|
3
|
+
Vega-Lite chart extension for [pi coding agent](https://github.com/badlogic/pi-mono) - render data visualizations as inline terminal images.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install @walterra/pi-charts
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or add to your pi config manually:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @walterra/pi-charts
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then in your pi config, add the extension path.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Declarative Visualizations**: Use Vega-Lite JSON specs to describe charts
|
|
22
|
+
- **Auto Dependencies**: Python, altair, pandas, vl-convert auto-installed via `uv`
|
|
23
|
+
- **Inline Display**: Charts render directly in terminals supporting inline images (Ghostty, Kitty, iTerm2, WezTerm)
|
|
24
|
+
- **Save to File**: Optionally save charts to PNG files
|
|
25
|
+
|
|
26
|
+
## Tool: `vega_chart`
|
|
27
|
+
|
|
28
|
+
Renders a Vega-Lite specification as a PNG image.
|
|
29
|
+
|
|
30
|
+
### Parameters
|
|
31
|
+
|
|
32
|
+
| Parameter | Type | Required | Description |
|
|
33
|
+
|-----------|------|----------|-------------|
|
|
34
|
+
| `spec` | string | ✅ | Vega-Lite JSON specification |
|
|
35
|
+
| `tsv_data` | string | | Optional TSV data to replace spec.data.values |
|
|
36
|
+
| `width` | number | | Chart width in pixels (default: 600) |
|
|
37
|
+
| `height` | number | | Chart height in pixels (default: 400) |
|
|
38
|
+
| `save_path` | string | | Optional file path to save the PNG |
|
|
39
|
+
|
|
40
|
+
### Example
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
|
45
|
+
"data": {
|
|
46
|
+
"values": [
|
|
47
|
+
{"category": "A", "value": 28},
|
|
48
|
+
{"category": "B", "value": 55},
|
|
49
|
+
{"category": "C", "value": 43}
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"mark": "bar",
|
|
53
|
+
"encoding": {
|
|
54
|
+
"x": {"field": "category", "type": "nominal"},
|
|
55
|
+
"y": {"field": "value", "type": "quantitative"}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Reference Documentation
|
|
61
|
+
|
|
62
|
+
See [vega-lite-reference.md](./extensions/vega-chart/vega-lite-reference.md) for comprehensive documentation on:
|
|
63
|
+
|
|
64
|
+
- Data types and encoding channels
|
|
65
|
+
- All mark types and properties
|
|
66
|
+
- Common pitfalls to avoid
|
|
67
|
+
- Professional chart patterns
|
|
68
|
+
- Theming and best practices
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vega-Lite Chart Extension
|
|
3
|
+
*
|
|
4
|
+
* Renders Vega-Lite specifications as PNG images in terminals that support
|
|
5
|
+
* inline images (Ghostty, Kitty, iTerm2, WezTerm).
|
|
6
|
+
*
|
|
7
|
+
* Philosophy (inspired by Bostock & Heer):
|
|
8
|
+
* - Declarative: The agent constructs a Vega-Lite JSON spec
|
|
9
|
+
* - Composable: Full control over marks, encodings, scales, layers
|
|
10
|
+
* - Data-driven: Inline data or separate TSV input
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
|
|
14
|
+
import { Type } from '@sinclair/typebox';
|
|
15
|
+
import { execSync } from 'node:child_process';
|
|
16
|
+
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
// Compute reference path using ESM import.meta.url
|
|
22
|
+
const VEGA_REFERENCE_PATH = join(dirname(fileURLToPath(import.meta.url)), 'vega-lite-reference.md');
|
|
23
|
+
|
|
24
|
+
export default function (pi: ExtensionAPI) {
|
|
25
|
+
pi.registerTool({
|
|
26
|
+
name: 'vega_chart',
|
|
27
|
+
label: 'Vega-Lite Chart',
|
|
28
|
+
description: `Render a Vega-Lite specification as a PNG image.
|
|
29
|
+
|
|
30
|
+
Dependencies are auto-installed via uv (Python package manager):
|
|
31
|
+
- uv itself is auto-installed if missing
|
|
32
|
+
- Python 3, altair, pandas, vl-convert-python are managed by uv
|
|
33
|
+
If setup fails, the tool returns installation instructions - do NOT fall back to ASCII charts.
|
|
34
|
+
|
|
35
|
+
IMPORTANT: Before using this tool, read the complete reference documentation at:
|
|
36
|
+
${VEGA_REFERENCE_PATH}
|
|
37
|
+
|
|
38
|
+
The reference contains critical information about:
|
|
39
|
+
- Data types (N, O, Q, T) and encoding channels
|
|
40
|
+
- All mark types and their properties
|
|
41
|
+
- Common pitfalls (dot-notation fields, label truncation, facet issues)
|
|
42
|
+
- Professional chart patterns with complete working examples
|
|
43
|
+
- Theming and best practices
|
|
44
|
+
|
|
45
|
+
Pass a complete Vega-Lite JSON spec. The agent has full control over:
|
|
46
|
+
- Mark types: bar, line, point, area, rect, arc, rule, text, boxplot, etc.
|
|
47
|
+
- Encodings: x, y, color, size, shape, opacity, row, column, etc.
|
|
48
|
+
- Scales: linear, log, sqrt, pow, time, utc, ordinal, band, point
|
|
49
|
+
- Aggregations: count, sum, mean, median, min, max, distinct, etc.
|
|
50
|
+
- Transforms: filter, calculate, aggregate, fold, pivot, window, etc.
|
|
51
|
+
- Composition: layer, hconcat, vconcat, facet, repeat
|
|
52
|
+
|
|
53
|
+
Data can be inline in the spec (values) or passed separately as TSV.
|
|
54
|
+
|
|
55
|
+
Example spec structure:
|
|
56
|
+
{
|
|
57
|
+
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
|
58
|
+
"data": { "values": [...] },
|
|
59
|
+
"mark": "bar",
|
|
60
|
+
"encoding": {
|
|
61
|
+
"x": { "field": "category", "type": "nominal" },
|
|
62
|
+
"y": { "field": "value", "type": "quantitative" }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
Reference: https://vega.github.io/vega-lite/docs/`,
|
|
67
|
+
parameters: Type.Object({
|
|
68
|
+
spec: Type.String({
|
|
69
|
+
description:
|
|
70
|
+
'Vega-Lite JSON specification (complete spec with $schema, data, mark, encoding)',
|
|
71
|
+
}),
|
|
72
|
+
tsv_data: Type.Optional(
|
|
73
|
+
Type.String({
|
|
74
|
+
description: 'Optional TSV data - if provided, replaces spec.data.values',
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
width: Type.Optional(Type.Number({ description: 'Chart width in pixels (default: 600)' })),
|
|
78
|
+
height: Type.Optional(Type.Number({ description: 'Chart height in pixels (default: 400)' })),
|
|
79
|
+
save_path: Type.Optional(
|
|
80
|
+
Type.String({
|
|
81
|
+
description: 'Optional file path to save the PNG chart (in addition to displaying it)',
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
}),
|
|
85
|
+
|
|
86
|
+
async execute(_toolCallId, params, _onUpdate, _ctx, signal) {
|
|
87
|
+
const {
|
|
88
|
+
spec,
|
|
89
|
+
tsv_data,
|
|
90
|
+
width = 600,
|
|
91
|
+
height = 400,
|
|
92
|
+
save_path,
|
|
93
|
+
} = params as {
|
|
94
|
+
spec: string;
|
|
95
|
+
tsv_data?: string;
|
|
96
|
+
width?: number;
|
|
97
|
+
height?: number;
|
|
98
|
+
save_path?: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (signal?.aborted) {
|
|
102
|
+
return { content: [{ type: 'text', text: 'Cancelled' }], details: {} };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
// Check Python and dependencies, auto-install if needed using uv
|
|
107
|
+
const ensureDependencies = (): { success: boolean; error?: string } => {
|
|
108
|
+
// Check if uv is available
|
|
109
|
+
let hasUv = false;
|
|
110
|
+
try {
|
|
111
|
+
execSync('which uv', { encoding: 'utf-8' });
|
|
112
|
+
hasUv = true;
|
|
113
|
+
} catch {
|
|
114
|
+
// uv not available, try to install it
|
|
115
|
+
const platform = process.platform;
|
|
116
|
+
try {
|
|
117
|
+
if (platform === 'win32') {
|
|
118
|
+
execSync('powershell -c "irm https://astral.sh/uv/install.ps1 | iex"', {
|
|
119
|
+
encoding: 'utf-8',
|
|
120
|
+
stdio: 'inherit',
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
execSync('curl -LsSf https://astral.sh/uv/install.sh | sh', {
|
|
124
|
+
encoding: 'utf-8',
|
|
125
|
+
stdio: 'inherit',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// Source the updated PATH or check common install locations
|
|
129
|
+
const uvPaths = [
|
|
130
|
+
`${process.env.HOME}/.local/bin/uv`,
|
|
131
|
+
`${process.env.HOME}/.cargo/bin/uv`,
|
|
132
|
+
'/usr/local/bin/uv',
|
|
133
|
+
];
|
|
134
|
+
hasUv = uvPaths.some((p) => {
|
|
135
|
+
try {
|
|
136
|
+
execSync(`${p} --version`, { encoding: 'utf-8' });
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
// uv install failed
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!hasUv) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
error:
|
|
151
|
+
'uv (Python package manager) not found and auto-install failed.\nPlease install uv: curl -LsSf https://astral.sh/uv/install.sh | sh',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Use uv to run Python with the required packages
|
|
156
|
+
// uv will auto-install Python and packages as needed
|
|
157
|
+
const checkCmd =
|
|
158
|
+
'uv run --with altair --with pandas --with vl-convert-python python3 -c "import altair; import pandas; import vl_convert"';
|
|
159
|
+
try {
|
|
160
|
+
execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' });
|
|
161
|
+
return { success: true };
|
|
162
|
+
} catch (err: any) {
|
|
163
|
+
return {
|
|
164
|
+
success: false,
|
|
165
|
+
error: `Failed to setup Python environment with uv.\nPlease run manually: uv run --with altair --with pandas --with vl-convert-python python3\n\nError: ${err.message}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const deps = ensureDependencies();
|
|
171
|
+
if (!deps.success) {
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: 'text', text: deps.error! }],
|
|
174
|
+
details: { error: 'Dependencies not installed' },
|
|
175
|
+
isError: true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Parse and validate the spec
|
|
180
|
+
let vegaSpec: any;
|
|
181
|
+
try {
|
|
182
|
+
vegaSpec = JSON.parse(spec);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return {
|
|
185
|
+
content: [{ type: 'text', text: `Invalid JSON in spec: ${e}` }],
|
|
186
|
+
details: { error: 'Invalid JSON' },
|
|
187
|
+
isError: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Add schema if missing
|
|
192
|
+
if (!vegaSpec.$schema) {
|
|
193
|
+
vegaSpec.$schema = 'https://vega.github.io/schema/vega-lite/v5.json';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Set dimensions if not specified
|
|
197
|
+
if (!vegaSpec.width) vegaSpec.width = width;
|
|
198
|
+
if (!vegaSpec.height) vegaSpec.height = height;
|
|
199
|
+
|
|
200
|
+
const tmpSpec = join(tmpdir(), `vega-spec-${Date.now()}.json`);
|
|
201
|
+
const tmpTsv = join(tmpdir(), `vega-data-${Date.now()}.tsv`);
|
|
202
|
+
const tmpPng = join(tmpdir(), `vega-chart-${Date.now()}.png`);
|
|
203
|
+
|
|
204
|
+
// If TSV data provided, we'll load it in Python
|
|
205
|
+
if (tsv_data) {
|
|
206
|
+
writeFileSync(tmpTsv, tsv_data);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
writeFileSync(tmpSpec, JSON.stringify(vegaSpec, null, 2));
|
|
210
|
+
|
|
211
|
+
// Python script to render with Altair
|
|
212
|
+
const pythonScript = `
|
|
213
|
+
import altair as alt
|
|
214
|
+
import pandas as pd
|
|
215
|
+
import json
|
|
216
|
+
|
|
217
|
+
# Load the Vega-Lite spec
|
|
218
|
+
with open('${tmpSpec}', 'r') as f:
|
|
219
|
+
spec = json.load(f)
|
|
220
|
+
|
|
221
|
+
# If TSV data provided, load it and inject into spec
|
|
222
|
+
tsv_path = ${tsv_data ? `'${tmpTsv}'` : 'None'}
|
|
223
|
+
if tsv_path:
|
|
224
|
+
df = pd.read_csv(tsv_path, sep='\\t')
|
|
225
|
+
# Convert DataFrame to list of dicts for Vega-Lite
|
|
226
|
+
spec['data'] = {'values': df.to_dict(orient='records')}
|
|
227
|
+
|
|
228
|
+
# Create chart from spec
|
|
229
|
+
chart = alt.Chart.from_dict(spec)
|
|
230
|
+
|
|
231
|
+
# Save as PNG with retina scale
|
|
232
|
+
chart.save('${tmpPng}', scale_factor=2)
|
|
233
|
+
print('OK')
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
const result = execSync(
|
|
237
|
+
`uv run --with altair --with pandas --with vl-convert-python python3 -c "${pythonScript.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`,
|
|
238
|
+
{
|
|
239
|
+
encoding: 'utf-8',
|
|
240
|
+
timeout: 60000, // Longer timeout for first run when uv downloads packages
|
|
241
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
if (!result.includes('OK')) {
|
|
246
|
+
throw new Error('Chart generation failed');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Read the PNG file as base64
|
|
250
|
+
const pngBuffer = readFileSync(tmpPng);
|
|
251
|
+
const base64Data = pngBuffer.toString('base64');
|
|
252
|
+
|
|
253
|
+
// If save_path provided, copy the PNG to that location
|
|
254
|
+
let savedPath: string | undefined;
|
|
255
|
+
if (save_path) {
|
|
256
|
+
const { copyFileSync, mkdirSync } = await import('node:fs');
|
|
257
|
+
const { dirname } = await import('node:path');
|
|
258
|
+
try {
|
|
259
|
+
// Ensure directory exists
|
|
260
|
+
mkdirSync(dirname(save_path), { recursive: true });
|
|
261
|
+
copyFileSync(tmpPng, save_path);
|
|
262
|
+
savedPath = save_path;
|
|
263
|
+
} catch (saveErr: any) {
|
|
264
|
+
// Don't fail the whole operation, just note the error
|
|
265
|
+
console.error(`Failed to save to ${save_path}: ${saveErr.message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Clean up temp files
|
|
270
|
+
try {
|
|
271
|
+
unlinkSync(tmpSpec);
|
|
272
|
+
} catch {}
|
|
273
|
+
try {
|
|
274
|
+
unlinkSync(tmpTsv);
|
|
275
|
+
} catch {}
|
|
276
|
+
try {
|
|
277
|
+
unlinkSync(tmpPng);
|
|
278
|
+
} catch {}
|
|
279
|
+
|
|
280
|
+
const dataPoints = tsv_data
|
|
281
|
+
? tsv_data.trim().split('\n').length - 1
|
|
282
|
+
: vegaSpec.data?.values?.length || 0;
|
|
283
|
+
|
|
284
|
+
const textMsg = savedPath
|
|
285
|
+
? `Rendered Vega-Lite chart (${dataPoints} data points) - saved to ${savedPath}`
|
|
286
|
+
: `Rendered Vega-Lite chart (${dataPoints} data points)`;
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
content: [
|
|
290
|
+
{ type: 'image', data: base64Data, mimeType: 'image/png' },
|
|
291
|
+
{ type: 'text', text: textMsg },
|
|
292
|
+
],
|
|
293
|
+
details: { dataPoints, width: vegaSpec.width, height: vegaSpec.height, savedPath },
|
|
294
|
+
};
|
|
295
|
+
} catch (error: any) {
|
|
296
|
+
// Try to extract Python error details
|
|
297
|
+
const errorMsg = error.stderr || error.message;
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: 'text', text: `Error rendering chart: ${errorMsg}` }],
|
|
300
|
+
details: { error: errorMsg },
|
|
301
|
+
isError: true,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|