@alasano/pi-read-summary 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 +47 -0
- package/assets/screenshot.png +0 -0
- package/extensions/batches.ts +49 -0
- package/extensions/index.ts +243 -0
- package/extensions/paths.ts +121 -0
- package/extensions/ranges.ts +47 -0
- package/extensions/read-result.ts +68 -0
- package/extensions/render.ts +94 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# pi-read-summary
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="assets/screenshot.png" alt="pi-read-summary screenshot" width="827" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
Compact grouped read summaries for [pi](https://pi.dev).
|
|
8
|
+
|
|
9
|
+
`pi-read-summary` replaces large individual per-file `read` tool blocks with one local summary for each contiguous batch of reads. It keeps the model-facing read behavior unchanged while making the terminal easier to scan.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:@alasano/pi-read-summary
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Behavior
|
|
18
|
+
|
|
19
|
+
- Overrides Pi's built-in `read` tool renderer while delegating execution to the built-in implementation.
|
|
20
|
+
- Groups contiguous `read` calls into one compact summary block.
|
|
21
|
+
- Shows relative paths for files inside the current working directory and absolute paths for files outside it.
|
|
22
|
+
|
|
23
|
+
Collapsed output stays compact:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
● Read 3 files
|
|
27
|
+
└─ "AGENTS.md" [Read 54 lines]
|
|
28
|
+
└─ "package.json" [Read 42 lines]
|
|
29
|
+
└─ "packages/coding-agent/src/core/tools/read.ts" [Read 120 lines]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Expanded output (ctrl+o) uses Pi's shows merged line ranges:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
● Read 3 files
|
|
36
|
+
└─ "AGENTS.md" [Read 54 lines]
|
|
37
|
+
lines 1-54
|
|
38
|
+
└─ "package.json" [Read 42 lines]
|
|
39
|
+
lines 1-40, lines 60-61
|
|
40
|
+
└─ "packages/coding-agent/src/core/tools/read.ts" [Read 120 lines]
|
|
41
|
+
lines 1-120
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- Pi `0.79.9` or newer.
|
|
47
|
+
- Node.js 22.19.0 or newer.
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { LineRange } from './ranges';
|
|
2
|
+
|
|
3
|
+
export type ReadBatchEntry = {
|
|
4
|
+
path: string;
|
|
5
|
+
ranges: LineRange[];
|
|
6
|
+
inFlight: number;
|
|
7
|
+
isImage: boolean;
|
|
8
|
+
failed: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ReadBatch = {
|
|
12
|
+
id: string;
|
|
13
|
+
anchorToolCallId: string;
|
|
14
|
+
entries: ReadBatchEntry[];
|
|
15
|
+
inFlight: number;
|
|
16
|
+
pendingFinalize: boolean;
|
|
17
|
+
done: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function getOrCreateEntry(batch: ReadBatch, path: string): ReadBatchEntry | undefined {
|
|
21
|
+
if (!path) return undefined;
|
|
22
|
+
|
|
23
|
+
const existing = batch.entries.find((entry) => entry.path === path);
|
|
24
|
+
if (existing) return existing;
|
|
25
|
+
|
|
26
|
+
const entry: ReadBatchEntry = {
|
|
27
|
+
path,
|
|
28
|
+
ranges: [],
|
|
29
|
+
inFlight: 0,
|
|
30
|
+
isImage: false,
|
|
31
|
+
failed: false,
|
|
32
|
+
};
|
|
33
|
+
batch.entries.push(entry);
|
|
34
|
+
return entry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function markEntryFailed(
|
|
38
|
+
batches: Map<string, ReadBatch>,
|
|
39
|
+
batchId: string,
|
|
40
|
+
path: string,
|
|
41
|
+
): void {
|
|
42
|
+
const batch = batches.get(batchId);
|
|
43
|
+
if (!batch) return;
|
|
44
|
+
|
|
45
|
+
const entry = getOrCreateEntry(batch, path);
|
|
46
|
+
if (entry) {
|
|
47
|
+
entry.failed = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { Component } from '@earendil-works/pi-tui';
|
|
2
|
+
import { Box } from '@earendil-works/pi-tui';
|
|
3
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
import { createReadTool, isToolCallEventType } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { getOrCreateEntry, markEntryFailed, type ReadBatch } from './batches';
|
|
6
|
+
import { normalizeDisplayPath } from './paths';
|
|
7
|
+
import {
|
|
8
|
+
getReadStartLine,
|
|
9
|
+
extractReadLineCount,
|
|
10
|
+
isImageReadResult,
|
|
11
|
+
type ToolContentBlock,
|
|
12
|
+
} from './read-result';
|
|
13
|
+
import { addLineRange } from './ranges';
|
|
14
|
+
import { createReadSummaryComponent } from './render';
|
|
15
|
+
|
|
16
|
+
type RenderContextWithToolCallId = {
|
|
17
|
+
toolCallId: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ReadSummaryMeta = {
|
|
21
|
+
__readSummary?: {
|
|
22
|
+
batchId: string;
|
|
23
|
+
toolCallId: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const batchesRef = new Map<string, ReadBatch>();
|
|
28
|
+
|
|
29
|
+
function emptyComponent(): Component {
|
|
30
|
+
return { render: () => [], invalidate() {} };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mergeDetailsWithMeta(
|
|
34
|
+
details: unknown,
|
|
35
|
+
meta: NonNullable<ReadSummaryMeta['__readSummary']>,
|
|
36
|
+
): Record<string, unknown> {
|
|
37
|
+
if (details && typeof details === 'object' && !Array.isArray(details)) {
|
|
38
|
+
return {
|
|
39
|
+
...(details as Record<string, unknown>),
|
|
40
|
+
__readSummary: meta,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { __readSummary: meta };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getSummaryMeta(details: unknown): ReadSummaryMeta['__readSummary'] | undefined {
|
|
48
|
+
if (!details || typeof details !== 'object' || Array.isArray(details)) return undefined;
|
|
49
|
+
|
|
50
|
+
const raw = (details as ReadSummaryMeta).__readSummary;
|
|
51
|
+
if (!raw) return undefined;
|
|
52
|
+
if (typeof raw.batchId !== 'string') return undefined;
|
|
53
|
+
if (typeof raw.toolCallId !== 'string') return undefined;
|
|
54
|
+
return raw;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function readSummaryExtension(pi: ExtensionAPI) {
|
|
58
|
+
const originalRead = createReadTool(process.cwd());
|
|
59
|
+
const batches = batchesRef;
|
|
60
|
+
const toolCallToBatch = new Map<string, string>();
|
|
61
|
+
const toolCallToPath = new Map<string, string>();
|
|
62
|
+
|
|
63
|
+
let activeBatchId: string | undefined;
|
|
64
|
+
let batchCounter = 0;
|
|
65
|
+
|
|
66
|
+
function clearState(): void {
|
|
67
|
+
batches.clear();
|
|
68
|
+
toolCallToBatch.clear();
|
|
69
|
+
toolCallToPath.clear();
|
|
70
|
+
activeBatchId = undefined;
|
|
71
|
+
batchCounter = 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createBatch(anchorToolCallId: string): ReadBatch {
|
|
75
|
+
batchCounter += 1;
|
|
76
|
+
const batch: ReadBatch = {
|
|
77
|
+
id: `read-batch-${batchCounter}`,
|
|
78
|
+
anchorToolCallId,
|
|
79
|
+
entries: [],
|
|
80
|
+
inFlight: 0,
|
|
81
|
+
pendingFinalize: false,
|
|
82
|
+
done: false,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
batches.set(batch.id, batch);
|
|
86
|
+
activeBatchId = batch.id;
|
|
87
|
+
return batch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function startReadCall(toolCallId: string, path: string): ReadBatch {
|
|
91
|
+
let batch = activeBatchId ? batches.get(activeBatchId) : undefined;
|
|
92
|
+
|
|
93
|
+
if (!batch || batch.done || batch.pendingFinalize) {
|
|
94
|
+
batch = createBatch(toolCallId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const entry = getOrCreateEntry(batch, path);
|
|
98
|
+
if (entry) {
|
|
99
|
+
entry.inFlight += 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
batch.inFlight += 1;
|
|
103
|
+
toolCallToBatch.set(toolCallId, batch.id);
|
|
104
|
+
toolCallToPath.set(toolCallId, path);
|
|
105
|
+
return batch;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function markActiveBatchPendingFinalize(): void {
|
|
109
|
+
if (!activeBatchId) return;
|
|
110
|
+
|
|
111
|
+
const batch = batches.get(activeBatchId);
|
|
112
|
+
if (!batch) {
|
|
113
|
+
activeBatchId = undefined;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
batch.pendingFinalize = true;
|
|
118
|
+
if (batch.inFlight === 0) {
|
|
119
|
+
batch.done = true;
|
|
120
|
+
activeBatchId = undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function completeReadCall(toolCallId: string): void {
|
|
125
|
+
const batchId = toolCallToBatch.get(toolCallId);
|
|
126
|
+
if (!batchId) return;
|
|
127
|
+
|
|
128
|
+
const batch = batches.get(batchId);
|
|
129
|
+
if (!batch) return;
|
|
130
|
+
|
|
131
|
+
batch.inFlight = Math.max(0, batch.inFlight - 1);
|
|
132
|
+
|
|
133
|
+
const path = toolCallToPath.get(toolCallId);
|
|
134
|
+
if (path) {
|
|
135
|
+
const entry = getOrCreateEntry(batch, path);
|
|
136
|
+
if (entry) {
|
|
137
|
+
entry.inFlight = Math.max(0, entry.inFlight - 1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (batch.pendingFinalize && batch.inFlight === 0) {
|
|
142
|
+
batch.done = true;
|
|
143
|
+
if (activeBatchId === batch.id) {
|
|
144
|
+
activeBatchId = undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
pi.on('tool_call', async (event) => {
|
|
150
|
+
if (isToolCallEventType('read', event)) {
|
|
151
|
+
startReadCall(event.toolCallId, normalizeDisplayPath(event.input.path));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
markActiveBatchPendingFinalize();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
pi.on('message_end', async (event) => {
|
|
159
|
+
if (event.message.role === 'assistant' && event.message.stopReason !== 'toolUse') {
|
|
160
|
+
markActiveBatchPendingFinalize();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
pi.on('agent_end', async () => {
|
|
165
|
+
markActiveBatchPendingFinalize();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
pi.on('session_start', async () => {
|
|
169
|
+
clearState();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
pi.registerTool({
|
|
173
|
+
name: 'read',
|
|
174
|
+
label: originalRead.label,
|
|
175
|
+
description: originalRead.description,
|
|
176
|
+
parameters: originalRead.parameters,
|
|
177
|
+
renderShell: 'self',
|
|
178
|
+
|
|
179
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
180
|
+
const path = normalizeDisplayPath(params.path, ctx.cwd);
|
|
181
|
+
let batchId = toolCallToBatch.get(toolCallId);
|
|
182
|
+
if (!batchId) {
|
|
183
|
+
batchId = startReadCall(toolCallId, path).id;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const result = await originalRead.execute(toolCallId, params, signal, onUpdate);
|
|
188
|
+
|
|
189
|
+
const batch = batches.get(batchId);
|
|
190
|
+
const entry = batch ? getOrCreateEntry(batch, path) : undefined;
|
|
191
|
+
if (entry) {
|
|
192
|
+
const resultLike = {
|
|
193
|
+
content: result.content as ToolContentBlock[] | undefined,
|
|
194
|
+
details: result.details,
|
|
195
|
+
};
|
|
196
|
+
entry.isImage = entry.isImage || isImageReadResult(resultLike);
|
|
197
|
+
|
|
198
|
+
if (!entry.isImage) {
|
|
199
|
+
const lineCount = extractReadLineCount(resultLike);
|
|
200
|
+
if (typeof lineCount === 'number') {
|
|
201
|
+
entry.ranges = addLineRange(entry.ranges, getReadStartLine(params), lineCount);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
...result,
|
|
208
|
+
details: mergeDetailsWithMeta(result.details, { batchId, toolCallId }),
|
|
209
|
+
};
|
|
210
|
+
} catch (error) {
|
|
211
|
+
markEntryFailed(batches, batchId, path);
|
|
212
|
+
throw error;
|
|
213
|
+
} finally {
|
|
214
|
+
completeReadCall(toolCallId);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
renderCall() {
|
|
219
|
+
return emptyComponent();
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
renderResult(result, options, theme, context: RenderContextWithToolCallId) {
|
|
223
|
+
let meta = getSummaryMeta(result.details);
|
|
224
|
+
if (!meta) {
|
|
225
|
+
const batchId = toolCallToBatch.get(context.toolCallId);
|
|
226
|
+
if (batchId) {
|
|
227
|
+
meta = { batchId, toolCallId: context.toolCallId };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!meta) return emptyComponent();
|
|
232
|
+
|
|
233
|
+
const batch = batches.get(meta.batchId);
|
|
234
|
+
if (!batch || batch.anchorToolCallId !== meta.toolCallId) {
|
|
235
|
+
return emptyComponent();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const box = new Box(1, 0);
|
|
239
|
+
box.addChild(createReadSummaryComponent(meta.batchId, batches, theme, options.expanded));
|
|
240
|
+
return box;
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as nodePath from 'node:path';
|
|
2
|
+
|
|
3
|
+
type ThemeColorKey = 'accent' | 'dim' | 'muted' | 'success' | 'syntaxString' | 'text' | 'warning';
|
|
4
|
+
|
|
5
|
+
export type ThemeLike = {
|
|
6
|
+
fg: (color: ThemeColorKey, text: string) => string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const DIR_GRADIENT_HEX = [
|
|
10
|
+
'#7f849c',
|
|
11
|
+
'#878ca2',
|
|
12
|
+
'#8f93a8',
|
|
13
|
+
'#979baf',
|
|
14
|
+
'#9fa3b5',
|
|
15
|
+
'#a7aabb',
|
|
16
|
+
'#afb2c1',
|
|
17
|
+
'#b7bac7',
|
|
18
|
+
'#bfc2ce',
|
|
19
|
+
'#c7c9d4',
|
|
20
|
+
'#cfd1da',
|
|
21
|
+
'#d7d9e0',
|
|
22
|
+
'#dfe0e6',
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
const FILE_COLOR = 'syntaxString';
|
|
26
|
+
const rgbCache = new Map<string, { r: number; g: number; b: number }>();
|
|
27
|
+
|
|
28
|
+
function isWithinDirectory(absolutePath: string, cwd: string): boolean {
|
|
29
|
+
const relativePath = nodePath.relative(cwd, absolutePath);
|
|
30
|
+
return (
|
|
31
|
+
relativePath === '' || (!relativePath.startsWith('..') && !nodePath.isAbsolute(relativePath))
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function toDisplaySeparators(path: string): string {
|
|
36
|
+
return path.replaceAll('\\', '/');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeDisplayPath(inputPath: unknown, cwd: string = process.cwd()): string {
|
|
40
|
+
if (typeof inputPath !== 'string') return '(unknown path)';
|
|
41
|
+
|
|
42
|
+
const trimmed = inputPath.trim();
|
|
43
|
+
const stripped = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
|
|
44
|
+
if (!stripped) return '(unknown path)';
|
|
45
|
+
|
|
46
|
+
const absolutePath = nodePath.isAbsolute(stripped)
|
|
47
|
+
? nodePath.normalize(stripped)
|
|
48
|
+
: nodePath.resolve(cwd, stripped);
|
|
49
|
+
|
|
50
|
+
if (isWithinDirectory(absolutePath, cwd)) {
|
|
51
|
+
const relativePath = nodePath.relative(cwd, absolutePath);
|
|
52
|
+
return toDisplaySeparators(relativePath || '.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return toDisplaySeparators(absolutePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } | undefined {
|
|
59
|
+
const cached = rgbCache.get(hex);
|
|
60
|
+
if (cached) return cached;
|
|
61
|
+
|
|
62
|
+
const cleaned = hex.trim().replace('#', '');
|
|
63
|
+
if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) return undefined;
|
|
64
|
+
|
|
65
|
+
const rgb = {
|
|
66
|
+
r: Number.parseInt(cleaned.slice(0, 2), 16),
|
|
67
|
+
g: Number.parseInt(cleaned.slice(2, 4), 16),
|
|
68
|
+
b: Number.parseInt(cleaned.slice(4, 6), 16),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
rgbCache.set(hex, rgb);
|
|
72
|
+
return rgb;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fgHex(hex: string, text: string): string {
|
|
76
|
+
const rgb = hexToRgb(hex);
|
|
77
|
+
if (!rgb) return text;
|
|
78
|
+
return `\x1b[38;2;${rgb.r};${rgb.g};${rgb.b}m${text}\x1b[39m`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getDirColorHex(depth: number, totalDirs: number): string {
|
|
82
|
+
if (totalDirs <= 1) return DIR_GRADIENT_HEX[0];
|
|
83
|
+
|
|
84
|
+
const t = depth / Math.max(1, totalDirs - 1);
|
|
85
|
+
const index = Math.round(t * (DIR_GRADIENT_HEX.length - 1));
|
|
86
|
+
return (
|
|
87
|
+
DIR_GRADIENT_HEX[Math.min(DIR_GRADIENT_HEX.length - 1, Math.max(0, index))] ??
|
|
88
|
+
DIR_GRADIENT_HEX[0]
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function stylePath(path: string, theme: ThemeLike): string {
|
|
93
|
+
const normalizedPath = toDisplaySeparators(path);
|
|
94
|
+
const hasLeadingSlash = normalizedPath.startsWith('/');
|
|
95
|
+
const segments = normalizedPath.split('/').filter((segment) => segment.length > 0);
|
|
96
|
+
|
|
97
|
+
if (segments.length === 0) {
|
|
98
|
+
return theme.fg(FILE_COLOR, normalizedPath || path);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (segments.length === 1 && !hasLeadingSlash) {
|
|
102
|
+
return theme.fg(FILE_COLOR, segments[0] ?? normalizedPath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const directoryCount = Math.max(0, segments.length - 1);
|
|
106
|
+
let styled = '';
|
|
107
|
+
|
|
108
|
+
if (hasLeadingSlash) {
|
|
109
|
+
styled += fgHex(getDirColorHex(0, Math.max(1, directoryCount)), '/');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (let index = 0; index < directoryCount; index += 1) {
|
|
113
|
+
const segment = segments[index];
|
|
114
|
+
if (segment) {
|
|
115
|
+
styled += fgHex(getDirColorHex(index, directoryCount), `${segment}/`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
styled += theme.fg(FILE_COLOR, segments[segments.length - 1] ?? normalizedPath);
|
|
120
|
+
return styled;
|
|
121
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type LineRange = {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function positiveInteger(value: unknown): number | undefined {
|
|
7
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
|
8
|
+
|
|
9
|
+
const integer = Math.floor(value);
|
|
10
|
+
return integer > 0 ? integer : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function mergeRanges(ranges: LineRange[]): LineRange[] {
|
|
14
|
+
const sorted = ranges
|
|
15
|
+
.filter((range) => range.start > 0 && range.end >= range.start)
|
|
16
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
17
|
+
|
|
18
|
+
const merged: LineRange[] = [];
|
|
19
|
+
for (const range of sorted) {
|
|
20
|
+
const last = merged.at(-1);
|
|
21
|
+
if (!last || range.start > last.end + 1) {
|
|
22
|
+
merged.push({ ...range });
|
|
23
|
+
} else {
|
|
24
|
+
last.end = Math.max(last.end, range.end);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return merged;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function addLineRange(ranges: LineRange[], start: number, lineCount: number): LineRange[] {
|
|
32
|
+
if (lineCount <= 0) return ranges;
|
|
33
|
+
|
|
34
|
+
return mergeRanges([...ranges, { start, end: start + lineCount - 1 }]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function countRangeLines(ranges: LineRange[]): number {
|
|
38
|
+
return ranges.reduce((sum, range) => sum + range.end - range.start + 1, 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatLineRange(range: LineRange): string {
|
|
42
|
+
return range.start === range.end ? `line ${range.start}` : `lines ${range.start}-${range.end}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatLineRanges(ranges: LineRange[]): string {
|
|
46
|
+
return ranges.map(formatLineRange).join(', ');
|
|
47
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { positiveInteger } from './ranges';
|
|
2
|
+
|
|
3
|
+
export type ToolContentBlock = {
|
|
4
|
+
type: string;
|
|
5
|
+
text?: string;
|
|
6
|
+
data?: string;
|
|
7
|
+
mimeType?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ReadResultLike = {
|
|
11
|
+
content?: ToolContentBlock[];
|
|
12
|
+
details?: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function getReadStartLine(args: unknown): number {
|
|
16
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return 1;
|
|
17
|
+
return positiveInteger((args as { offset?: unknown }).offset) ?? 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isImageReadResult(result: ReadResultLike): boolean {
|
|
21
|
+
return result.content?.some((block) => block.type === 'image') === true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function findTextContent(content: ToolContentBlock[] | undefined): string | undefined {
|
|
25
|
+
if (!content) return undefined;
|
|
26
|
+
|
|
27
|
+
const textBlock = content.find(
|
|
28
|
+
(block) => block.type === 'text' && typeof block.text === 'string',
|
|
29
|
+
);
|
|
30
|
+
return textBlock?.text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractLineCountFromText(text: string): number {
|
|
34
|
+
const showingMatch = text.match(
|
|
35
|
+
/\[Showing lines (\d+)-(\d+) of \d+(?: \([^)]+\))?\. Use offset=\d+ to continue\.\]\s*$/,
|
|
36
|
+
);
|
|
37
|
+
if (showingMatch) {
|
|
38
|
+
const start = Number.parseInt(showingMatch[1] ?? '', 10);
|
|
39
|
+
const end = Number.parseInt(showingMatch[2] ?? '', 10);
|
|
40
|
+
if (!Number.isNaN(start) && !Number.isNaN(end) && end >= start) {
|
|
41
|
+
return end - start + 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const textWithoutNotice = text
|
|
46
|
+
.replace(
|
|
47
|
+
/\n\n\[Showing lines \d+-\d+ of \d+(?: \([^)]+\))?\. Use offset=\d+ to continue\.\]\s*$/,
|
|
48
|
+
'',
|
|
49
|
+
)
|
|
50
|
+
.replace(/\n\n\[\d+ more lines in file\. Use offset=\d+ to continue\.\]\s*$/, '');
|
|
51
|
+
|
|
52
|
+
if (!textWithoutNotice) return 0;
|
|
53
|
+
return textWithoutNotice.split('\n').length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function extractReadLineCount(result: ReadResultLike): number | undefined {
|
|
57
|
+
if (result.details && typeof result.details === 'object' && !Array.isArray(result.details)) {
|
|
58
|
+
const truncation = (result.details as { truncation?: { outputLines?: unknown } }).truncation;
|
|
59
|
+
if (truncation && typeof truncation.outputLines === 'number') {
|
|
60
|
+
return truncation.outputLines;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const text = findTextContent(result.content);
|
|
65
|
+
if (typeof text !== 'string') return undefined;
|
|
66
|
+
|
|
67
|
+
return extractLineCountFromText(text);
|
|
68
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Component } from '@earendil-works/pi-tui';
|
|
2
|
+
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
3
|
+
import type { ReadBatch, ReadBatchEntry } from './batches';
|
|
4
|
+
import { stylePath, type ThemeLike } from './paths';
|
|
5
|
+
import { countRangeLines, formatLineRanges } from './ranges';
|
|
6
|
+
|
|
7
|
+
function pluralizeFiles(count: number): string {
|
|
8
|
+
return `${count} file${count === 1 ? '' : 's'}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pluralizeLines(count: number): string {
|
|
12
|
+
return `${count} line${count === 1 ? '' : 's'}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getEntryStatus(entry: ReadBatchEntry, theme: ThemeLike): string {
|
|
16
|
+
if (entry.failed) {
|
|
17
|
+
return `${theme.fg('dim', '[')}${theme.fg('warning', 'Read failed')}${theme.fg('dim', ']')}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (entry.isImage) {
|
|
21
|
+
return `${theme.fg('dim', '[')}${theme.fg('muted', 'Read image')}${theme.fg('dim', ']')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const uniqueLineCount = countRangeLines(entry.ranges);
|
|
25
|
+
if (uniqueLineCount > 0) {
|
|
26
|
+
return `${theme.fg('dim', '[')}${theme.fg('muted', `Read ${pluralizeLines(uniqueLineCount)}`)}${theme.fg('dim', ']')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (entry.inFlight > 0) {
|
|
30
|
+
return `${theme.fg('dim', '[')}${theme.fg('muted', 'Reading…')}${theme.fg('dim', ']')}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `${theme.fg('dim', '[')}${theme.fg('muted', 'Read file')}${theme.fg('dim', ']')}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatEntryLine(entry: ReadBatchEntry, theme: ThemeLike, width: number): string {
|
|
37
|
+
const prefix = theme.fg('dim', '└─ "');
|
|
38
|
+
const path = stylePath(entry.path, theme);
|
|
39
|
+
const suffix = theme.fg('dim', '"');
|
|
40
|
+
const annotation = getEntryStatus(entry, theme);
|
|
41
|
+
const suffixAndAnnotation = `${suffix} ${annotation}`;
|
|
42
|
+
const pathWidth = width - visibleWidth(prefix) - visibleWidth(suffixAndAnnotation);
|
|
43
|
+
|
|
44
|
+
if (pathWidth <= 0) {
|
|
45
|
+
return truncateToWidth(`${prefix}${path}${suffixAndAnnotation}`, width);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `${prefix}${truncateToWidth(path, pathWidth, '…')}${suffixAndAnnotation}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createReadSummaryComponent(
|
|
52
|
+
batchId: string,
|
|
53
|
+
batches: Map<string, ReadBatch>,
|
|
54
|
+
theme: ThemeLike,
|
|
55
|
+
expanded: boolean,
|
|
56
|
+
): Component {
|
|
57
|
+
return {
|
|
58
|
+
render(width: number): string[] {
|
|
59
|
+
const batch = batches.get(batchId);
|
|
60
|
+
if (!batch) return [];
|
|
61
|
+
|
|
62
|
+
const safeWidth = Math.max(1, width);
|
|
63
|
+
const fileCount = batch.entries.length;
|
|
64
|
+
const failedCount = batch.entries.filter((entry) => entry.failed).length;
|
|
65
|
+
const statusDot = batch.done ? theme.fg('success', '●') : theme.fg('text', '○');
|
|
66
|
+
const statusText = batch.done
|
|
67
|
+
? theme.fg('success', `Read ${pluralizeFiles(fileCount)}`)
|
|
68
|
+
: theme.fg('accent', `Reading ${pluralizeFiles(fileCount)}…`);
|
|
69
|
+
|
|
70
|
+
let firstLine = `${statusDot} ${statusText}`;
|
|
71
|
+
if (failedCount > 0) {
|
|
72
|
+
firstLine += ` ${theme.fg('warning', `(${failedCount} failed)`)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lines = [truncateToWidth(firstLine, safeWidth)];
|
|
76
|
+
|
|
77
|
+
for (const entry of batch.entries) {
|
|
78
|
+
lines.push(formatEntryLine(entry, theme, safeWidth));
|
|
79
|
+
|
|
80
|
+
if (expanded && entry.ranges.length > 0) {
|
|
81
|
+
lines.push(
|
|
82
|
+
truncateToWidth(
|
|
83
|
+
`${theme.fg('dim', ' ')} ${theme.fg('muted', formatLineRanges(entry.ranges))}`,
|
|
84
|
+
safeWidth,
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return lines;
|
|
91
|
+
},
|
|
92
|
+
invalidate() {},
|
|
93
|
+
};
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alasano/pi-read-summary",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Compact grouped read summaries for pi",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/alasano/house-of-pi",
|
|
12
|
+
"directory": "packages/pi-read-summary"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.19.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./extensions"
|
|
24
|
+
],
|
|
25
|
+
"image": "https://raw.githubusercontent.com/alasano/house-of-pi/master/packages/pi-read-summary/assets/screenshot.png"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"extensions",
|
|
29
|
+
"assets",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": ">=0.79.9",
|
|
34
|
+
"@earendil-works/pi-tui": ">=0.79.9"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "^0.79.9",
|
|
38
|
+
"@earendil-works/pi-tui": "^0.79.9"
|
|
39
|
+
}
|
|
40
|
+
}
|