@jtsang/pi-extensions 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 +8 -6
- package/extensions/monitor.ts +177 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -23,8 +23,10 @@ keeping the model running between events.
|
|
|
23
23
|
- `pi_background_monitor_stop` stops one monitor by ID, or all monitors when no
|
|
24
24
|
ID is provided.
|
|
25
25
|
- Monitors run concurrently with no extension-level limit; operating-system
|
|
26
|
-
process and resource limits still apply.
|
|
27
|
-
|
|
26
|
+
process and resource limits still apply. In the TUI, the active count appears
|
|
27
|
+
below the default editor: press Down when the editor cannot move farther,
|
|
28
|
+
then Enter to open details or Up/Escape to return. `/monitors` shows the same
|
|
29
|
+
details without invoking the model.
|
|
28
30
|
- Output from stdout and stderr is batched for 200 ms, stripped of terminal
|
|
29
31
|
control sequences, limited to 16 KB per event, and delivered to the current
|
|
30
32
|
conversation as untrusted data. An idle agent starts a turn immediately; a
|
|
@@ -66,7 +68,8 @@ pnpm check
|
|
|
66
68
|
```
|
|
67
69
|
|
|
68
70
|
Pi loads TypeScript directly through jiti, so this package intentionally has no
|
|
69
|
-
build
|
|
71
|
+
build artifact. `pnpm verify` runs tests, TypeScript checking, and the publish
|
|
72
|
+
package inspection; `prepublishOnly` runs it automatically before publishing.
|
|
70
73
|
|
|
71
74
|
Small extensions belong in `extensions/<name>.ts`. Multi-file extensions use
|
|
72
75
|
`extensions/<name>/index.ts`. Keep shared code in `lib/` only after real reuse
|
|
@@ -75,9 +78,8 @@ appears.
|
|
|
75
78
|
Before publishing:
|
|
76
79
|
|
|
77
80
|
```sh
|
|
78
|
-
pnpm
|
|
79
|
-
pnpm
|
|
80
|
-
pnpm publish
|
|
81
|
+
pnpm verify
|
|
82
|
+
pnpm publish # runs pnpm verify again via prepublishOnly
|
|
81
83
|
```
|
|
82
84
|
|
|
83
85
|
## License
|
package/extensions/monitor.ts
CHANGED
|
@@ -2,13 +2,25 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { StringDecoder } from "node:string_decoder";
|
|
3
3
|
import { stripVTControlCharacters } from "node:util";
|
|
4
4
|
import {
|
|
5
|
+
CustomEditor,
|
|
5
6
|
createLocalBashOperations,
|
|
6
7
|
formatSize,
|
|
8
|
+
keyHint,
|
|
7
9
|
truncateHead,
|
|
8
10
|
truncateTail,
|
|
9
11
|
type ExtensionAPI,
|
|
10
12
|
type ExtensionContext,
|
|
13
|
+
type KeybindingsManager,
|
|
14
|
+
type Theme,
|
|
11
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
Text,
|
|
18
|
+
truncateToWidth,
|
|
19
|
+
type Component,
|
|
20
|
+
type EditorComponent,
|
|
21
|
+
type Focusable,
|
|
22
|
+
type TUI,
|
|
23
|
+
} from "@earendil-works/pi-tui";
|
|
12
24
|
import { Type } from "typebox";
|
|
13
25
|
|
|
14
26
|
const BATCH_DELAY_MS = 200;
|
|
@@ -18,6 +30,7 @@ const MAX_EVENT_BYTES = 16 * 1024;
|
|
|
18
30
|
const MAX_QUEUED_BYTES = MAX_EVENT_BYTES * 2;
|
|
19
31
|
const MAX_QUEUED_LINES = 200;
|
|
20
32
|
const STATUS_KEY = "jtsang4-background-monitors";
|
|
33
|
+
const WIDGET_KEY = "jtsang4-background-monitor-navigator";
|
|
21
34
|
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u000d\u000e-\u001f\u007f]/g;
|
|
22
35
|
|
|
23
36
|
type RunningMonitor = {
|
|
@@ -41,6 +54,64 @@ type RunningMonitor = {
|
|
|
41
54
|
done: Promise<void>;
|
|
42
55
|
};
|
|
43
56
|
|
|
57
|
+
class MonitorStatusWidget implements Component, Focusable {
|
|
58
|
+
focused = false;
|
|
59
|
+
private editor?: EditorComponent;
|
|
60
|
+
private keybindings?: KeybindingsManager;
|
|
61
|
+
private readonly tui: TUI;
|
|
62
|
+
private readonly theme: Theme;
|
|
63
|
+
private readonly getCount: () => number;
|
|
64
|
+
private readonly openDetails: () => void;
|
|
65
|
+
|
|
66
|
+
constructor(tui: TUI, theme: Theme, getCount: () => number, openDetails: () => void) {
|
|
67
|
+
this.tui = tui;
|
|
68
|
+
this.theme = theme;
|
|
69
|
+
this.getCount = getCount;
|
|
70
|
+
this.openDetails = openDetails;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
connectEditor(editor: EditorComponent, keybindings: KeybindingsManager): void {
|
|
74
|
+
this.editor = editor;
|
|
75
|
+
this.keybindings = keybindings;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
focus(): void {
|
|
79
|
+
this.tui.setFocus(this);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
focusEditor(): void {
|
|
83
|
+
if (this.editor) this.tui.setFocus(this.editor);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
refresh(): void {
|
|
87
|
+
if (this.getCount() === 0 && this.focused) this.focusEditor();
|
|
88
|
+
this.tui.requestRender();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
handleInput(data: string): void {
|
|
92
|
+
if (this.keybindings?.matches(data, "tui.select.confirm")) {
|
|
93
|
+
this.openDetails();
|
|
94
|
+
} else if (
|
|
95
|
+
this.keybindings?.matches(data, "tui.select.up") ||
|
|
96
|
+
this.keybindings?.matches(data, "tui.select.cancel")
|
|
97
|
+
) {
|
|
98
|
+
this.focusEditor();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
invalidate(): void {}
|
|
103
|
+
|
|
104
|
+
render(width: number): string[] {
|
|
105
|
+
const count = this.getCount();
|
|
106
|
+
if (count === 0) return [];
|
|
107
|
+
const label = `${this.focused ? "›" : " "} ${count} monitor${count === 1 ? "" : "s"}`;
|
|
108
|
+
const text = this.focused
|
|
109
|
+
? this.theme.bg("selectedBg", this.theme.fg("accent", label))
|
|
110
|
+
: this.theme.fg("dim", label);
|
|
111
|
+
return [truncateToWidth(text, width)];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
44
115
|
function sanitizeLine(line: string): string {
|
|
45
116
|
return stripVTControlCharacters(line).replace(CONTROL_CHARACTERS, "");
|
|
46
117
|
}
|
|
@@ -49,16 +120,34 @@ function escapeXml(text: string): string {
|
|
|
49
120
|
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
50
121
|
}
|
|
51
122
|
|
|
123
|
+
function getTextContent(content: unknown): string {
|
|
124
|
+
if (typeof content === "string") return content;
|
|
125
|
+
if (!Array.isArray(content)) return "";
|
|
126
|
+
return content
|
|
127
|
+
.map((part) =>
|
|
128
|
+
part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part
|
|
129
|
+
? String(part.text)
|
|
130
|
+
: "",
|
|
131
|
+
)
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
52
136
|
export default function monitorExtension(pi: ExtensionAPI) {
|
|
53
137
|
const shell = createLocalBashOperations();
|
|
54
138
|
const monitors = new Map<string, RunningMonitor>();
|
|
55
139
|
let currentContext: ExtensionContext | undefined;
|
|
140
|
+
let monitorWidget: MonitorStatusWidget | undefined;
|
|
56
141
|
let shuttingDown = false;
|
|
57
142
|
|
|
58
143
|
function updateStatus(): void {
|
|
59
144
|
if (!currentContext?.hasUI) return;
|
|
60
145
|
const count = monitors.size;
|
|
61
|
-
currentContext.ui.setStatus(
|
|
146
|
+
currentContext.ui.setStatus(
|
|
147
|
+
STATUS_KEY,
|
|
148
|
+
currentContext.mode === "tui" || count === 0 ? undefined : `${count} monitor${count === 1 ? "" : "s"}`,
|
|
149
|
+
);
|
|
150
|
+
monitorWidget?.refresh();
|
|
62
151
|
}
|
|
63
152
|
|
|
64
153
|
function getMonitorDetails() {
|
|
@@ -97,6 +186,90 @@ export default function monitorExtension(pi: ExtensionAPI) {
|
|
|
97
186
|
: truncated.content;
|
|
98
187
|
}
|
|
99
188
|
|
|
189
|
+
async function showMonitorDetails(ctx: ExtensionContext): Promise<void> {
|
|
190
|
+
await ctx.ui.custom<void>(
|
|
191
|
+
(_tui, theme, keybindings, done) => {
|
|
192
|
+
const text = new Text("", 1, 1);
|
|
193
|
+
return {
|
|
194
|
+
handleInput(data: string) {
|
|
195
|
+
if (
|
|
196
|
+
keybindings.matches(data, "tui.select.confirm") ||
|
|
197
|
+
keybindings.matches(data, "tui.select.cancel")
|
|
198
|
+
) {
|
|
199
|
+
done();
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
invalidate: () => text.invalidate(),
|
|
203
|
+
render(width: number) {
|
|
204
|
+
text.setText(
|
|
205
|
+
`${formatMonitorList()}\n\n${theme.fg("dim", `${keyHint("tui.select.confirm", "close")} · ${keyHint("tui.select.cancel", "close")}`)}`,
|
|
206
|
+
);
|
|
207
|
+
return text.render(width);
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
overlay: true,
|
|
213
|
+
overlayOptions: { width: "80%", maxHeight: "80%", anchor: "center", margin: 1 },
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
if (monitors.size === 0) monitorWidget?.focusEditor();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function setupMonitorNavigation(ctx: ExtensionContext, restoreHistory: boolean): void {
|
|
220
|
+
if (ctx.mode !== "tui") return;
|
|
221
|
+
const previousEditorFactory = ctx.ui.getEditorComponent();
|
|
222
|
+
|
|
223
|
+
ctx.ui.setWidget(
|
|
224
|
+
WIDGET_KEY,
|
|
225
|
+
(tui, theme) => {
|
|
226
|
+
monitorWidget = new MonitorStatusWidget(tui, theme, () => monitors.size, () => {
|
|
227
|
+
void showMonitorDetails(ctx);
|
|
228
|
+
});
|
|
229
|
+
return monitorWidget;
|
|
230
|
+
},
|
|
231
|
+
{ placement: "belowEditor" },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
if (previousEditorFactory) return;
|
|
235
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
236
|
+
const editor = new CustomEditor(tui, theme, keybindings);
|
|
237
|
+
monitorWidget?.connectEditor(editor, keybindings);
|
|
238
|
+
if (restoreHistory) {
|
|
239
|
+
for (const entry of ctx.sessionManager.buildContextEntries()) {
|
|
240
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
241
|
+
const text = getTextContent(entry.message.content);
|
|
242
|
+
if (text) editor.addToHistory(text);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const handleInput = editor.handleInput.bind(editor);
|
|
247
|
+
editor.handleInput = (data: string) => {
|
|
248
|
+
if (
|
|
249
|
+
monitors.size === 0 ||
|
|
250
|
+
editor.isShowingAutocomplete() ||
|
|
251
|
+
!keybindings.matches(data, "tui.editor.cursorDown")
|
|
252
|
+
) {
|
|
253
|
+
handleInput(data);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const beforeCursor = editor.getCursor();
|
|
258
|
+
const beforeText = editor.getText();
|
|
259
|
+
handleInput(data);
|
|
260
|
+
const afterCursor = editor.getCursor();
|
|
261
|
+
if (
|
|
262
|
+
beforeText === editor.getText() &&
|
|
263
|
+
beforeCursor.line === afterCursor.line &&
|
|
264
|
+
beforeCursor.col === afterCursor.col
|
|
265
|
+
) {
|
|
266
|
+
monitorWidget?.focus();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
return editor;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
100
273
|
function discardQueuedOutput(monitor: RunningMonitor): void {
|
|
101
274
|
if (monitor.flushTimer) clearTimeout(monitor.flushTimer);
|
|
102
275
|
monitor.flushTimer = undefined;
|
|
@@ -221,9 +394,10 @@ export default function monitorExtension(pi: ExtensionAPI) {
|
|
|
221
394
|
await monitor.done;
|
|
222
395
|
}
|
|
223
396
|
|
|
224
|
-
pi.on("session_start", (
|
|
397
|
+
pi.on("session_start", (event, ctx) => {
|
|
225
398
|
currentContext = ctx;
|
|
226
399
|
shuttingDown = false;
|
|
400
|
+
setupMonitorNavigation(ctx, event.reason !== "startup");
|
|
227
401
|
updateStatus();
|
|
228
402
|
});
|
|
229
403
|
|
|
@@ -233,6 +407,7 @@ export default function monitorExtension(pi: ExtensionAPI) {
|
|
|
233
407
|
await Promise.allSettled(active.map(stopMonitor));
|
|
234
408
|
monitors.clear();
|
|
235
409
|
updateStatus();
|
|
410
|
+
monitorWidget = undefined;
|
|
236
411
|
currentContext = undefined;
|
|
237
412
|
});
|
|
238
413
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jtsang/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "An opinionated collection of extensions for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"check": "pnpm pack --dry-run",
|
|
54
|
-
"test": "node --test --experimental-strip-types tests/*.test.ts"
|
|
54
|
+
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
|
55
|
+
"verify": "pnpm test && pnpm exec tsc --noEmit && pnpm check"
|
|
55
56
|
}
|
|
56
57
|
}
|