pi-extension-utils 0.3.0 → 0.3.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.
Files changed (61) hide show
  1. package/README.md +90 -27
  2. package/dist/index.js +9 -140
  3. package/dist/src/client/index.d.ts +7 -0
  4. package/dist/src/client/index.js +25 -0
  5. package/dist/src/client/types.d.ts +17 -0
  6. package/dist/src/client/types.js +1 -0
  7. package/dist/src/config/index.d.ts +28 -0
  8. package/dist/src/config/index.js +222 -0
  9. package/dist/src/index.d.ts +10 -6
  10. package/dist/src/index.js +10 -6
  11. package/dist/src/logger/config.d.ts +11 -0
  12. package/dist/src/logger/config.js +27 -0
  13. package/dist/src/logger/index.d.ts +20 -0
  14. package/dist/src/logger/index.js +76 -0
  15. package/dist/src/{tui-chrome.js → pane/chrome.js} +22 -13
  16. package/dist/src/pane/overlay.d.ts +75 -0
  17. package/dist/src/pane/overlay.js +492 -0
  18. package/dist/src/reminders/client.d.ts +10 -0
  19. package/dist/src/reminders/client.js +49 -0
  20. package/dist/src/reminders/config.d.ts +5 -6
  21. package/dist/src/reminders/config.js +9 -23
  22. package/dist/src/reminders/debug.js +2 -2
  23. package/dist/src/reminders/host.d.ts +5 -0
  24. package/dist/src/reminders/host.js +298 -0
  25. package/dist/src/reminders/index.d.ts +3 -5
  26. package/dist/src/reminders/index.js +3 -308
  27. package/dist/src/reminders/types.d.ts +3 -0
  28. package/dist/src/reminders/types.js +3 -0
  29. package/dist/src/ui/client.d.ts +22 -0
  30. package/dist/src/ui/client.js +17 -0
  31. package/dist/src/utils-config.d.ts +24 -0
  32. package/dist/src/utils-config.js +38 -0
  33. package/dist/src/widgets/client.d.ts +27 -0
  34. package/dist/src/widgets/client.js +130 -0
  35. package/dist/src/widgets/host.d.ts +2 -0
  36. package/dist/src/widgets/host.js +143 -0
  37. package/docs/README.md +11 -0
  38. package/docs/client.md +91 -0
  39. package/docs/config.md +86 -0
  40. package/docs/pane-overlay.md +88 -0
  41. package/docs/reference/reminders-spec.md +351 -0
  42. package/docs/reminders.md +68 -0
  43. package/docs/widgets.md +57 -0
  44. package/examples/README.md +23 -0
  45. package/examples/config.ts +31 -0
  46. package/examples/logger.ts +20 -0
  47. package/examples/pane-overlay.ts +47 -0
  48. package/examples/reminders.ts +25 -0
  49. package/examples/widget-coordinator.ts +18 -0
  50. package/package.json +12 -1
  51. package/dist/src/client.d.ts +0 -47
  52. package/dist/src/client.js +0 -194
  53. package/dist/src/logger.d.ts +0 -12
  54. package/dist/src/logger.js +0 -45
  55. /package/dist/src/{tui-chrome.d.ts → pane/chrome.d.ts} +0 -0
  56. /package/dist/src/{key-dispatch.d.ts → pane/key-dispatch.d.ts} +0 -0
  57. /package/dist/src/{key-dispatch.js → pane/key-dispatch.js} +0 -0
  58. /package/dist/src/{pane-state.d.ts → pane/state.d.ts} +0 -0
  59. /package/dist/src/{pane-state.js → pane/state.js} +0 -0
  60. /package/dist/src/{protocol.d.ts → widgets/protocol.d.ts} +0 -0
  61. /package/dist/src/{protocol.js → widgets/protocol.js} +0 -0
@@ -0,0 +1,492 @@
1
+ import { dispatchNavKeys } from "./key-dispatch.js";
2
+ import { computeFixedSidebarLayout, computeSplitPaneLayout, endCursor, endScrollOffset, ensureCursorVisible, homeCursor, homeScrollOffset, moveCursor, moveScrollOffset, pageCursor, pageScrollOffset, resizeSplitPane, } from "./state.js";
3
+ import { boxRow, clipStyled, flatRule, formatScrollInfo, padRight, renderKeyRow, titledBottomSegment, titledTopSegment, } from "./chrome.js";
4
+ import { matchesKey } from "@earendil-works/pi-tui";
5
+ const DEFAULT_CLOSE_KEYS = ["escape", "ctrl+c", "q"];
6
+ function resolveValue(value, ctx) {
7
+ return typeof value === "function" ? value(ctx) : value;
8
+ }
9
+ function computeBodyHeight(tui, height) {
10
+ if (typeof height === "number")
11
+ return Math.max(1, height);
12
+ if (typeof height === "function")
13
+ return Math.max(1, height(tui));
14
+ const rows = tui?.terminal?.rows;
15
+ if (typeof rows === "number")
16
+ return Math.max(8, rows - 2);
17
+ return 16;
18
+ }
19
+ function matchesAny(data, binding) {
20
+ if (!binding)
21
+ return false;
22
+ const keys = typeof binding === "string" ? [binding] : binding;
23
+ return keys.some((key) => data === key || matchesKey(data, key));
24
+ }
25
+ function defaultRenderRow(row) {
26
+ return typeof row === "string" ? row : String(row);
27
+ }
28
+ function decorateRow(text, theme) {
29
+ return theme.bold ? theme.bold(theme.fg("accent", text)) : theme.fg("accent", text);
30
+ }
31
+ export function paneOverlay(options) {
32
+ const split = options.split ?? {};
33
+ const closeKeys = options.closeKeys ?? DEFAULT_CLOSE_KEYS;
34
+ const customActions = options.customActions ?? [];
35
+ const bannedKeys = options.bannedKeys ?? [];
36
+ const legendPlacement = options.legendPlacement ?? "footer";
37
+ const perSelectionScroll = options.perSelectionScroll ?? false;
38
+ const stickyBottom = options.stickyBottom ?? false;
39
+ const collapse = options.collapse;
40
+ const collapseKey = collapse?.key ?? "c";
41
+ const collapsedWidth = collapse?.collapsedWidth ?? 1;
42
+ return (tui, theme, _keybindings, done) => {
43
+ const chromeTheme = theme;
44
+ let finished = false;
45
+ let focus = "primary";
46
+ let collapsed = false;
47
+ let leftFraction = split.initialFraction ?? 0.5;
48
+ let lastRenderWidth = 80;
49
+ let lastSelectedKey;
50
+ const primaryState = { cursor: 0, scrollOffset: 0 };
51
+ const detailState = { scrollOffset: 0, sticky: stickyBottom };
52
+ const perSelectionOffsets = new Map();
53
+ const requestRender = () => {
54
+ tui?.requestRender?.();
55
+ };
56
+ const finish = (result) => {
57
+ if (finished)
58
+ return;
59
+ finished = true;
60
+ done(result);
61
+ };
62
+ const makeContext = (selectedIndex, selectedRow, selectedKey, primaryRows, detailRows) => ({
63
+ tui,
64
+ primaryFocus: focus === "primary",
65
+ detailFocus: focus === "detail",
66
+ selectedRow,
67
+ selectedIndex,
68
+ selectedKey,
69
+ primary: {
70
+ mode: options.primary.mode ?? "scroll",
71
+ cursor: primaryState.cursor,
72
+ scrollOffset: primaryState.scrollOffset,
73
+ },
74
+ detail: { scrollOffset: detailState.scrollOffset },
75
+ close: (result) => finish(result),
76
+ requestRender,
77
+ });
78
+ const getLayout = (totalWidth) => {
79
+ if (collapsed) {
80
+ return computeFixedSidebarLayout({
81
+ totalWidth,
82
+ collapsed: true,
83
+ leftWidth: collapsedWidth,
84
+ collapsedLeftWidth: collapsedWidth,
85
+ minLeftWidth: split.minPrimaryWidth ?? 0,
86
+ minRightWidth: split.minDetailWidth ?? 8,
87
+ });
88
+ }
89
+ return computeSplitPaneLayout({
90
+ totalWidth,
91
+ leftFraction,
92
+ minLeftWidth: split.minPrimaryWidth ?? 8,
93
+ minRightWidth: split.minDetailWidth ?? 8,
94
+ leftMaxWidth: split.maxPrimaryWidth,
95
+ minFraction: split.minFraction,
96
+ maxFraction: split.maxFraction,
97
+ fractionBasis: split.fractionBasis,
98
+ });
99
+ };
100
+ const resize = (direction) => {
101
+ if (collapsed)
102
+ return;
103
+ const next = resizeSplitPane({
104
+ totalWidth: lastRenderWidth,
105
+ leftFraction,
106
+ direction,
107
+ stepCols: split.stepCols ?? 4,
108
+ minLeftWidth: split.minPrimaryWidth ?? 8,
109
+ minRightWidth: split.minDetailWidth ?? 8,
110
+ leftMaxWidth: split.maxPrimaryWidth,
111
+ minFraction: split.minFraction,
112
+ maxFraction: split.maxFraction,
113
+ fractionBasis: split.fractionBasis,
114
+ });
115
+ leftFraction = next.leftFraction;
116
+ };
117
+ const toggleFocus = () => {
118
+ focus = focus === "primary" ? "detail" : "primary";
119
+ };
120
+ const ensureFocusDetailWhenCollapsed = () => {
121
+ if (collapsed)
122
+ focus = "detail";
123
+ };
124
+ const getDetailStateForKey = (key) => {
125
+ let state = perSelectionOffsets.get(key);
126
+ if (!state) {
127
+ state = { offset: 0, sticky: stickyBottom };
128
+ perSelectionOffsets.set(key, state);
129
+ }
130
+ return state;
131
+ };
132
+ const listViewport = (bodyHeight, legendLineCount) => {
133
+ const primaryHeight = legendPlacement === "primary"
134
+ ? Math.max(0, bodyHeight - legendLineCount - 1)
135
+ : bodyHeight;
136
+ return { primaryHeight, detailHeight: bodyHeight };
137
+ };
138
+ const pageSizeHalf = (height) => Math.max(1, Math.floor(height / 2));
139
+ const withPrimaryViewport = (bodyHeight, legendLineCount) => {
140
+ const primaryRows = resolveValue(options.primary.rows, makeContext(0, undefined, "", [], []));
141
+ const viewportHeight = listViewport(bodyHeight, legendLineCount).primaryHeight;
142
+ return { primaryRows, viewportHeight: Math.max(1, viewportHeight) };
143
+ };
144
+ const applyPrimaryNav = (bodyHeight, legendLineCount, mutator) => {
145
+ const { primaryRows, viewportHeight } = withPrimaryViewport(bodyHeight, legendLineCount);
146
+ mutator(primaryState, primaryRows, viewportHeight);
147
+ };
148
+ const movePrimary = (delta, bodyHeight, legendLineCount) => {
149
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
150
+ if ((options.primary.mode ?? "scroll") === "cursor") {
151
+ const next = moveCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight }, delta);
152
+ state.cursor = next.cursor;
153
+ state.scrollOffset = next.scroll;
154
+ }
155
+ else {
156
+ state.scrollOffset = moveScrollOffset({ offset: state.scrollOffset, contentLength: rows.length, viewportHeight }, delta);
157
+ }
158
+ });
159
+ };
160
+ const halfPagePrimary = (delta, bodyHeight, legendLineCount) => {
161
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
162
+ const pageSize = pageSizeHalf(viewportHeight);
163
+ if ((options.primary.mode ?? "scroll") === "cursor") {
164
+ const next = pageCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight }, delta, pageSize);
165
+ state.cursor = next.cursor;
166
+ state.scrollOffset = next.scroll;
167
+ }
168
+ else {
169
+ state.scrollOffset = pageScrollOffset({ offset: state.scrollOffset, contentLength: rows.length, viewportHeight }, delta, pageSize);
170
+ }
171
+ });
172
+ };
173
+ const homePrimary = (bodyHeight, legendLineCount) => {
174
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
175
+ if ((options.primary.mode ?? "scroll") === "cursor") {
176
+ const next = homeCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
177
+ state.cursor = next.cursor;
178
+ state.scrollOffset = next.scroll;
179
+ }
180
+ else {
181
+ state.scrollOffset = homeScrollOffset();
182
+ }
183
+ });
184
+ };
185
+ const endPrimary = (bodyHeight, legendLineCount) => {
186
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
187
+ if ((options.primary.mode ?? "scroll") === "cursor") {
188
+ const next = endCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
189
+ state.cursor = next.cursor;
190
+ state.scrollOffset = next.scroll;
191
+ }
192
+ else {
193
+ state.scrollOffset = endScrollOffset(rows.length, viewportHeight);
194
+ }
195
+ });
196
+ };
197
+ const markDetailManual = () => {
198
+ detailState.sticky = false;
199
+ if (perSelectionScroll && lastSelectedKey !== undefined) {
200
+ getDetailStateForKey(lastSelectedKey).sticky = false;
201
+ }
202
+ };
203
+ const legendEntries = (ctx) => {
204
+ const entries = [];
205
+ entries.push({ key: "tab/←/→", label: "focus" });
206
+ const primaryMode = options.primary.mode ?? "scroll";
207
+ const moveLabel = ctx.detailFocus || primaryMode === "scroll" ? "scroll" : "select";
208
+ entries.push({ key: "j/k", label: moveLabel });
209
+ entries.push({ key: "u/d", label: "half-page" });
210
+ entries.push({ key: "g/G", label: "top/bottom" });
211
+ if (!collapsed)
212
+ entries.push({ key: "[/]", label: "resize" });
213
+ if (collapse)
214
+ entries.push({ key: collapseKey, label: collapse.label ?? "collapse" });
215
+ for (const action of customActions) {
216
+ if (action.showInLegend === false)
217
+ continue;
218
+ if (action.when && !action.when(ctx))
219
+ continue;
220
+ entries.push({ key: typeof action.keys === "string" ? action.keys : action.keys.join("/"), label: action.label });
221
+ }
222
+ const qCloses = closeKeys.includes("q");
223
+ entries.push({ key: qCloses ? "q/esc" : "esc", label: "close" });
224
+ return entries;
225
+ };
226
+ const renderLegendLines = (entries, totalWidth) => {
227
+ if (entries.length === 0 || totalWidth <= 0)
228
+ return [];
229
+ const keyWidth = Math.min(11, Math.max(3, Math.floor(totalWidth / 4)));
230
+ return entries.map((entry) => chromeTheme.fg("dim", renderKeyRow(entry.key, entry.label, totalWidth, keyWidth)));
231
+ };
232
+ const computeSelection = (bodyHeight) => {
233
+ const provisionalCtx = makeContext(0, undefined, "", [], []);
234
+ const primaryRows = resolveValue(options.primary.rows, provisionalCtx);
235
+ const primaryMode = options.primary.mode ?? "scroll";
236
+ let selectedIndex = 0;
237
+ if (primaryMode === "cursor") {
238
+ selectedIndex = ensureCursorVisible({
239
+ cursor: primaryState.cursor,
240
+ scroll: primaryState.scrollOffset,
241
+ itemCount: primaryRows.length,
242
+ viewportHeight: Math.max(1, bodyHeight),
243
+ }).cursor;
244
+ }
245
+ else {
246
+ selectedIndex = Math.min(primaryState.scrollOffset, Math.max(0, primaryRows.length - 1));
247
+ }
248
+ if (primaryRows.length === 0)
249
+ selectedIndex = 0;
250
+ const selectedRow = primaryRows[selectedIndex];
251
+ const selectionKeyFn = options.primary.selectionKey ?? ((_row, index) => String(index));
252
+ const selectedKey = selectedRow !== undefined ? selectionKeyFn(selectedRow, selectedIndex) : String(selectedIndex);
253
+ return { primaryRows, selectedIndex, selectedRow, selectedKey };
254
+ };
255
+ const component = {
256
+ dispose() {
257
+ // no-op is acceptable
258
+ },
259
+ render(width) {
260
+ const totalWidth = Math.max(10, Math.floor(width));
261
+ lastRenderWidth = totalWidth;
262
+ const bodyHeight = computeBodyHeight(tui, options.height);
263
+ const layout = getLayout(totalWidth);
264
+ const primaryWidth = layout.leftWidth;
265
+ const detailWidth = layout.rightWidth;
266
+ const { primaryRows, selectedIndex, selectedRow, selectedKey } = computeSelection(bodyHeight);
267
+ const detailRows = resolveValue(options.detail.rows, makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, []));
268
+ const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, detailRows);
269
+ if (selectedKey !== lastSelectedKey) {
270
+ lastSelectedKey = selectedKey;
271
+ if (options.primary.onSelectionChange) {
272
+ options.primary.onSelectionChange(selectedRow, selectedIndex, selectedKey, ctx);
273
+ }
274
+ if (perSelectionScroll) {
275
+ const state = getDetailStateForKey(selectedKey);
276
+ detailState.scrollOffset = state.offset;
277
+ detailState.sticky = state.sticky;
278
+ }
279
+ else if (stickyBottom) {
280
+ detailState.sticky = true;
281
+ }
282
+ }
283
+ ensureFocusDetailWhenCollapsed();
284
+ const entries = legendEntries(ctx);
285
+ const legendLines = renderLegendLines(entries, totalWidth);
286
+ const { primaryHeight, detailHeight } = listViewport(bodyHeight, legendLines.length);
287
+ // Align primary viewport.
288
+ if ((options.primary.mode ?? "scroll") === "cursor") {
289
+ const viewport = ensureCursorVisible({
290
+ cursor: primaryState.cursor,
291
+ scroll: primaryState.scrollOffset,
292
+ itemCount: primaryRows.length,
293
+ viewportHeight: Math.max(1, primaryHeight),
294
+ });
295
+ primaryState.cursor = viewport.cursor;
296
+ primaryState.scrollOffset = viewport.scroll;
297
+ }
298
+ else {
299
+ primaryState.scrollOffset = Math.max(0, Math.min(primaryState.scrollOffset, Math.max(0, primaryRows.length - primaryHeight)));
300
+ }
301
+ // Align detail viewport.
302
+ const detailMaxOffset = Math.max(0, detailRows.length - detailHeight);
303
+ if (stickyBottom && detailState.sticky) {
304
+ detailState.scrollOffset = detailMaxOffset;
305
+ }
306
+ else {
307
+ detailState.scrollOffset = Math.max(0, Math.min(detailState.scrollOffset, detailMaxOffset));
308
+ }
309
+ if (perSelectionScroll) {
310
+ const state = getDetailStateForKey(selectedKey);
311
+ state.offset = detailState.scrollOffset;
312
+ state.sticky = detailState.sticky;
313
+ }
314
+ const primaryTitle = resolveValue(options.primary.title, ctx);
315
+ const detailTitle = resolveValue(options.detail.title, ctx);
316
+ const topPrimary = titledTopSegment(chromeTheme, {
317
+ width: primaryWidth,
318
+ label: primaryTitle ?? "",
319
+ labelColor: focus === "primary" ? "accent" : "text",
320
+ labelBold: focus === "primary",
321
+ });
322
+ const topDetail = titledTopSegment(chromeTheme, {
323
+ width: detailWidth,
324
+ label: detailTitle ?? "",
325
+ labelColor: focus === "detail" ? "accent" : "text",
326
+ labelBold: focus === "detail",
327
+ });
328
+ const corner = (glyph) => chromeTheme.fg("dim", glyph);
329
+ const topBorder = primaryWidth === 0
330
+ ? corner("╭") + topDetail + corner("╮")
331
+ : corner("╭") + topPrimary + corner("┬") + topDetail + corner("╮");
332
+ const renderFn = options.primary.renderRow ?? defaultRenderRow;
333
+ const primaryVisible = primaryRows
334
+ .slice(primaryState.scrollOffset, primaryState.scrollOffset + primaryHeight)
335
+ .map((row, index) => {
336
+ const absoluteIndex = primaryState.scrollOffset + index;
337
+ const text = renderFn(row, ctx);
338
+ const selected = (options.primary.mode ?? "scroll") === "cursor" && absoluteIndex === primaryState.cursor && focus === "primary";
339
+ return selected ? decorateRow(text, chromeTheme) : text;
340
+ });
341
+ const detailVisible = detailRows.slice(detailState.scrollOffset, detailState.scrollOffset + detailHeight);
342
+ const primaryFooterText = resolveValue(options.primary.footer, ctx)
343
+ ?? ((options.primary.mode ?? "scroll") === "cursor" && primaryRows.length > 0
344
+ ? `${primaryState.cursor + 1}/${primaryRows.length}`
345
+ : primaryRows.length > primaryHeight
346
+ ? formatScrollInfo(primaryState.scrollOffset, Math.max(0, primaryRows.length - primaryHeight), { style: "position" })
347
+ : "");
348
+ const detailFooterText = resolveValue(options.detail.footer, ctx)
349
+ ?? (detailRows.length > detailHeight
350
+ ? formatScrollInfo(detailState.scrollOffset, Math.max(0, detailRows.length - detailHeight), { style: "position" })
351
+ : "");
352
+ const bottomPrimary = titledBottomSegment(chromeTheme, primaryWidth, primaryFooterText, focus === "primary");
353
+ const bottomDetail = titledBottomSegment(chromeTheme, detailWidth, detailFooterText, focus === "detail");
354
+ const bottomBorder = primaryWidth === 0
355
+ ? corner("╰") + bottomDetail + corner("╯")
356
+ : corner("╰") + bottomPrimary + corner("┴") + bottomDetail + corner("╯");
357
+ const bodyLines = [];
358
+ for (let row = 0; row < bodyHeight; row++) {
359
+ let primaryCell = primaryVisible[row] ?? "";
360
+ if (legendPlacement === "primary" && row === primaryHeight && legendLines.length > 0) {
361
+ primaryCell = flatRule(chromeTheme, collapse?.label ?? "actions", primaryWidth);
362
+ }
363
+ if (legendPlacement === "primary" && row > primaryHeight) {
364
+ const legendIndex = row - primaryHeight - 1;
365
+ primaryCell = legendIndex < legendLines.length ? padRight(legendLines[legendIndex], primaryWidth) : "";
366
+ }
367
+ const detailCell = detailVisible[row] ?? "";
368
+ if (primaryWidth === 0) {
369
+ const border = chromeTheme.fg("dim", "│");
370
+ bodyLines.push(border + padRight(clipStyled(detailCell, detailWidth), detailWidth) + border);
371
+ }
372
+ else {
373
+ bodyLines.push(boxRow(chromeTheme, primaryCell, detailCell, primaryWidth, detailWidth));
374
+ }
375
+ }
376
+ const lines = [topBorder, ...bodyLines, bottomBorder];
377
+ if (legendPlacement === "footer") {
378
+ lines.push(...legendLines);
379
+ }
380
+ return lines.map((line) => padRight(line, totalWidth));
381
+ },
382
+ handleInput(data) {
383
+ if (finished)
384
+ return;
385
+ const bodyHeight = computeBodyHeight(tui, options.height);
386
+ if (matchesAny(data, closeKeys)) {
387
+ finish(options.closeResult);
388
+ return;
389
+ }
390
+ const { primaryRows, selectedIndex, selectedRow, selectedKey } = computeSelection(bodyHeight);
391
+ const detailRows = resolveValue(options.detail.rows, makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, []));
392
+ const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, detailRows);
393
+ for (const action of customActions) {
394
+ if (action.when && !action.when(ctx))
395
+ continue;
396
+ if (matchesAny(data, action.keys)) {
397
+ action.run(ctx);
398
+ requestRender();
399
+ return;
400
+ }
401
+ }
402
+ if (matchesAny(data, bannedKeys)) {
403
+ requestRender();
404
+ return;
405
+ }
406
+ if (matchesAny(data, ["left", "right", "tab"])) {
407
+ toggleFocus();
408
+ ensureFocusDetailWhenCollapsed();
409
+ requestRender();
410
+ return;
411
+ }
412
+ const legendLineCount = legendPlacement === "primary" ? renderLegendLines(legendEntries(ctx), lastRenderWidth).length : 0;
413
+ if (matchesAny(data, ["pageUp", "pageDown"]))
414
+ return;
415
+ const commonConsumed = dispatchNavKeys(data, {
416
+ close: () => undefined,
417
+ closeKeys: [],
418
+ move: (delta) => {
419
+ if (focus === "primary") {
420
+ movePrimary(delta, bodyHeight, legendLineCount);
421
+ }
422
+ else {
423
+ markDetailManual();
424
+ detailState.scrollOffset = moveScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, delta);
425
+ }
426
+ },
427
+ home: () => {
428
+ if (focus === "primary") {
429
+ homePrimary(bodyHeight, legendLineCount);
430
+ }
431
+ else {
432
+ markDetailManual();
433
+ detailState.scrollOffset = homeScrollOffset();
434
+ }
435
+ },
436
+ end: () => {
437
+ if (focus === "primary") {
438
+ endPrimary(bodyHeight, legendLineCount);
439
+ }
440
+ else {
441
+ markDetailManual();
442
+ detailState.scrollOffset = endScrollOffset(detailRows.length, Math.max(1, bodyHeight));
443
+ }
444
+ },
445
+ });
446
+ if (commonConsumed) {
447
+ requestRender();
448
+ return;
449
+ }
450
+ if (matchesAny(data, ["u", "U"])) {
451
+ if (focus === "primary") {
452
+ halfPagePrimary(-1, bodyHeight, legendLineCount);
453
+ }
454
+ else {
455
+ markDetailManual();
456
+ detailState.scrollOffset = pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, -1, pageSizeHalf(bodyHeight));
457
+ }
458
+ requestRender();
459
+ return;
460
+ }
461
+ if (matchesAny(data, ["d", "D"])) {
462
+ if (focus === "primary") {
463
+ halfPagePrimary(1, bodyHeight, legendLineCount);
464
+ }
465
+ else {
466
+ markDetailManual();
467
+ detailState.scrollOffset = pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, 1, pageSizeHalf(bodyHeight));
468
+ }
469
+ requestRender();
470
+ return;
471
+ }
472
+ if (collapse && matchesAny(data, [collapseKey])) {
473
+ collapsed = !collapsed;
474
+ ensureFocusDetailWhenCollapsed();
475
+ requestRender();
476
+ return;
477
+ }
478
+ if (!collapsed && matchesAny(data, ["["])) {
479
+ resize(-1);
480
+ requestRender();
481
+ return;
482
+ }
483
+ if (!collapsed && matchesAny(data, ["]"])) {
484
+ resize(1);
485
+ requestRender();
486
+ return;
487
+ }
488
+ },
489
+ };
490
+ return component;
491
+ };
492
+ }
@@ -0,0 +1,10 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { type ReminderAnnounceNowRequest, type ReminderIntent, type ReminderSnapshot } from "./types.ts";
3
+ export interface RemindersClient {
4
+ upsert(intent: ReminderIntent): void;
5
+ remove(source: string, key: string): void;
6
+ clearSource(source: string): void;
7
+ announceNow(payload?: ReminderAnnounceNowRequest): void;
8
+ list(source?: string): Promise<ReminderSnapshot>;
9
+ }
10
+ export declare function createRemindersClient(pi: ExtensionAPI, isDisposed: () => boolean): RemindersClient;
@@ -0,0 +1,49 @@
1
+ import { REMINDER_ANNOUNCE_NOW_EVENT, REMINDER_CLEAR_SOURCE_EVENT, REMINDER_LIST_EVENT, REMINDER_REMOVE_EVENT, REMINDER_UPSERT_EVENT, } from "./types.js";
2
+ export function createRemindersClient(pi, isDisposed) {
3
+ return {
4
+ upsert(intent) {
5
+ if (isDisposed())
6
+ return;
7
+ pi.events.emit(REMINDER_UPSERT_EVENT, intent);
8
+ },
9
+ remove(source, key) {
10
+ if (isDisposed())
11
+ return;
12
+ pi.events.emit(REMINDER_REMOVE_EVENT, { source, id: key });
13
+ },
14
+ clearSource(source) {
15
+ if (isDisposed())
16
+ return;
17
+ pi.events.emit(REMINDER_CLEAR_SOURCE_EVENT, { source });
18
+ },
19
+ announceNow(payload = {}) {
20
+ if (isDisposed())
21
+ return;
22
+ pi.events.emit(REMINDER_ANNOUNCE_NOW_EVENT, payload);
23
+ },
24
+ list(source) {
25
+ if (isDisposed())
26
+ return Promise.resolve(emptyReminderSnapshot());
27
+ let settled = false;
28
+ let resolveSnapshot;
29
+ let rejectSnapshot;
30
+ const promise = new Promise((resolve, reject) => {
31
+ resolveSnapshot = (snapshot) => {
32
+ settled = true;
33
+ resolve(snapshot);
34
+ };
35
+ rejectSnapshot = (error) => {
36
+ settled = true;
37
+ reject(error);
38
+ };
39
+ });
40
+ pi.events.emit(REMINDER_LIST_EVENT, { source, resolve: resolveSnapshot, reject: rejectSnapshot });
41
+ if (!settled)
42
+ resolveSnapshot(emptyReminderSnapshot());
43
+ return promise;
44
+ },
45
+ };
46
+ }
47
+ function emptyReminderSnapshot() {
48
+ return { reminders: [], count: 0 };
49
+ }
@@ -1,6 +1,5 @@
1
- export interface RemindersConfig {
2
- debugShowAllInTui?: boolean;
3
- }
4
- export declare function configPath(cwd: string): string;
5
- export declare function loadConfig(cwd: string): RemindersConfig;
6
- export declare function saveConfig(config: RemindersConfig, cwd: string): void;
1
+ import { Type, type Static } from "typebox";
2
+ export declare const remindersConfigSchema: Type.TObject<{
3
+ debugShowAllInTui: Type.TBoolean;
4
+ }>;
5
+ export type RemindersConfig = Static<typeof remindersConfigSchema>;
@@ -1,23 +1,9 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- export function configPath(cwd) {
4
- return join(cwd, ".pi", "reminders", "reminders-config.json");
5
- }
6
- export function loadConfig(cwd) {
7
- try {
8
- const parsed = JSON.parse(readFileSync(configPath(cwd), "utf8"));
9
- if (!parsed || typeof parsed !== "object")
10
- return {};
11
- return {
12
- debugShowAllInTui: typeof parsed.debugShowAllInTui === "boolean" ? parsed.debugShowAllInTui : undefined,
13
- };
14
- }
15
- catch {
16
- return {};
17
- }
18
- }
19
- export function saveConfig(config, cwd) {
20
- const filePath = configPath(cwd);
21
- mkdirSync(dirname(filePath), { recursive: true });
22
- writeFileSync(filePath, JSON.stringify(config, null, 2));
23
- }
1
+ import { Type } from "typebox";
2
+ export const remindersConfigSchema = Type.Object({
3
+ debugShowAllInTui: Type.Boolean({
4
+ default: false,
5
+ description: "Show reminders with display:false in the transcript UI for debugging.",
6
+ }),
7
+ }, {
8
+ description: "Reminder host settings.",
9
+ });
@@ -1,9 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { appendFileSync, mkdirSync } from "node:fs";
3
- import { homedir } from "node:os";
4
3
  import { dirname, join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { deriveLabel } from "./renderer.js";
6
- const DEBUG_LOG_PATH = join(homedir(), ".pi", "log", "reminders.jsonl");
6
+ const DEBUG_LOG_PATH = join(getAgentDir(), "log", "reminders.jsonl");
7
7
  export function buildReminderCacheDiagnostics(input) {
8
8
  const before = getProviderMessages(input.payload);
9
9
  const after = getProviderMessages(input.resultPayload);
@@ -0,0 +1,5 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ReminderManager } from "./manager.ts";
3
+ /** Register the shared reminder host hooks and event listeners. */
4
+ export declare function registerReminderHost(pi: ExtensionAPI, manager?: ReminderManager): ReminderManager;
5
+ export default function reminderHost(pi: ExtensionAPI): void;