@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 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
+ }