@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 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. The footer shows the active count,
27
- and `/monitors` shows the current details without invoking the model.
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 step. Run `pnpm exec tsc --noEmit` after adding TypeScript sources.
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 exec tsc --noEmit
79
- pnpm check
80
- pnpm publish
81
+ pnpm verify
82
+ pnpm publish # runs pnpm verify again via prepublishOnly
81
83
  ```
82
84
 
83
85
  ## License
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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(STATUS_KEY, count ? `${count} monitor${count === 1 ? "" : "s"}` : undefined);
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", (_event, ctx) => {
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.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
  }