pi-extension-utils 0.4.0 → 0.5.0

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.
@@ -1,6 +1,6 @@
1
1
  export { connect, type FullscreenComponentFactory, type FullscreenLease, type RemindersClient, type UtilsClient, type UtilsClientOptions, type WidgetSetOptions } from "./client/index.ts";
2
2
  export { defineConfig, type DeepPartial, type DefinedConfig, type DefineConfigOptions } from "./config/index.ts";
3
- export { createLogger, LOG_LEVELS, loggerLevelSchema, loggingConfigSchema, type Logger, type LoggerLevel, type LoggerOptions, type LoggerRecord, type LoggingConfig, type LogLevel } from "./logger/index.ts";
3
+ export { createLogger, LOG_LEVELS, loggerLevelSchema, loggingConfigSchema, type Logger, type LoggerFields, type LoggerLevel, type LoggerOptions, type LoggerRecord, type LoggingConfig, type LogLevel } from "./logger/index.ts";
4
4
  export { utilsConfigSchema, type UtilsConfig } from "./utils-config.ts";
5
5
  export * from "./pane/key-dispatch.ts";
6
6
  export * from "./pane/state.ts";
@@ -8,12 +8,13 @@ export interface LoggerOptions {
8
8
  /** Minimum level to write. Defaults to utilsConfig.logging.level. */
9
9
  level?: LoggerLevel;
10
10
  }
11
+ export type LoggerFields = Record<string, unknown>;
11
12
  export interface Logger {
12
- debug(message: string): void;
13
- info(message: string): void;
14
- warn(message: string): void;
15
- error(message: string): void;
16
- log(level: LogLevel, message: string): void;
13
+ debug(message: string, fields?: LoggerFields): void;
14
+ info(message: string, fields?: LoggerFields): void;
15
+ warn(message: string, fields?: LoggerFields): void;
16
+ error(message: string, fields?: LoggerFields): void;
17
+ log(level: LogLevel, message: string, fields?: LoggerFields): void;
17
18
  setLevel(level: LoggerLevel): void;
18
19
  isEnabled(level: LogLevel): boolean;
19
20
  }
@@ -21,5 +22,6 @@ export interface LoggerRecord {
21
22
  ts: string;
22
23
  level: LogLevel;
23
24
  message: string;
25
+ [field: string]: unknown;
24
26
  }
25
27
  export declare function createLogger(name: string, opts?: LoggerOptions): Logger;
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { readUtilsConfigOrDefaults } from "../utils-config.js";
5
5
  export { LOG_LEVELS, loggerLevelSchema, loggingConfigSchema } from "./config.js";
6
+ const RESERVED_RECORD_FIELDS = new Set(["ts", "level", "message"]);
6
7
  const LEVEL_WEIGHT = {
7
8
  debug: 10,
8
9
  info: 20,
@@ -46,26 +47,31 @@ export function createLogger(name, opts = {}) {
46
47
  function isEnabled(candidate) {
47
48
  return LEVEL_WEIGHT[candidate] >= LEVEL_WEIGHT[level];
48
49
  }
49
- function log(candidate, message) {
50
+ function log(candidate, message, fields = {}) {
50
51
  if (!isEnabled(candidate))
51
52
  return;
52
- const line = `${JSON.stringify({ ts: new Date().toISOString(), level: candidate, message })}\n`;
53
+ const record = { ts: new Date().toISOString(), level: candidate, message };
54
+ for (const [key, value] of Object.entries(fields)) {
55
+ if (!RESERVED_RECORD_FIELDS.has(key))
56
+ record[key] = value;
57
+ }
58
+ const line = `${JSON.stringify(record)}\n`;
53
59
  mkdirSync(dirname(file), { recursive: true });
54
60
  rotateIfNeeded(Buffer.byteLength(line));
55
61
  appendFileSync(file, line);
56
62
  }
57
63
  return {
58
- debug(message) {
59
- log("debug", message);
64
+ debug(message, fields) {
65
+ log("debug", message, fields);
60
66
  },
61
- info(message) {
62
- log("info", message);
67
+ info(message, fields) {
68
+ log("info", message, fields);
63
69
  },
64
- warn(message) {
65
- log("warn", message);
70
+ warn(message, fields) {
71
+ log("warn", message, fields);
66
72
  },
67
- error(message) {
68
- log("error", message);
73
+ error(message, fields) {
74
+ log("error", message, fields);
69
75
  },
70
76
  log,
71
77
  setLevel(nextLevel) {
@@ -10,13 +10,30 @@ export interface PaneOverlayContext<T = unknown, Row = unknown> {
10
10
  mode: "cursor" | "scroll";
11
11
  cursor: number;
12
12
  scrollOffset: number;
13
+ width: number;
13
14
  };
14
15
  detail: {
15
16
  scrollOffset: number;
17
+ width: number;
16
18
  };
17
19
  close(result?: T): void;
18
20
  requestRender(): void;
19
21
  }
22
+ export interface PaneOverlaySeparatorRow {
23
+ kind: "separator";
24
+ label?: string;
25
+ }
26
+ export type PaneOverlayPrimaryRow<Row = unknown> = Row | PaneOverlaySeparatorRow;
27
+ export interface PaneOverlayTitle {
28
+ label: string;
29
+ tail?: string;
30
+ tailRendered?: string;
31
+ tailPlain?: string;
32
+ labelColor?: string;
33
+ tailColor?: string;
34
+ labelBold?: boolean;
35
+ }
36
+ type PaneOverlayTitleValue<Row> = string | PaneOverlayTitle | ((ctx: PaneOverlayContext<unknown, Row>) => string | PaneOverlayTitle);
20
37
  export interface PaneOverlayCustomAction<T = unknown, Row = unknown> {
21
38
  keys: string | readonly string[];
22
39
  label: string;
@@ -26,16 +43,20 @@ export interface PaneOverlayCustomAction<T = unknown, Row = unknown> {
26
43
  }
27
44
  export interface PrimaryPaneOptions<Row = unknown> {
28
45
  mode?: "cursor" | "scroll";
29
- rows: Row[] | ((ctx: PaneOverlayContext<unknown, Row>) => Row[]);
30
- renderRow?(row: Row, ctx: PaneOverlayContext<unknown, Row>): string;
46
+ rows: PaneOverlayPrimaryRow<Row>[] | ((ctx: PaneOverlayContext<unknown, Row>) => PaneOverlayPrimaryRow<Row>[]);
47
+ renderRow?(row: Row, ctx: PaneOverlayContext<unknown, Row>, width: number): string;
31
48
  selectionKey?(row: Row, index: number): string;
49
+ initialSelectionKey?: string;
50
+ initialIndex?: number;
32
51
  onSelectionChange?(row: Row | undefined, index: number, key: string, ctx: PaneOverlayContext<unknown, Row>): void;
33
- title?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
52
+ title?: PaneOverlayTitleValue<Row>;
53
+ info?: string[] | ((ctx: PaneOverlayContext<unknown, Row>) => string[]);
54
+ infoTitle?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
34
55
  footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
35
56
  }
36
57
  export interface DetailPaneOptions<Row = unknown> {
37
58
  rows(ctx: PaneOverlayContext<unknown, Row>): string[];
38
- title?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
59
+ title?: PaneOverlayTitleValue<Row>;
39
60
  footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
40
61
  }
41
62
  export interface PaneOverlaySplitOptions {
@@ -66,6 +87,7 @@ export interface PaneOverlayOptions<T = unknown, Row = unknown> {
66
87
  collapse?: PaneCollapseOptions;
67
88
  perSelectionScroll?: boolean;
68
89
  stickyBottom?: boolean;
90
+ onRender?(ctx: PaneOverlayContext<T, Row>): void;
69
91
  }
70
92
  export interface PaneOverlayComponent {
71
93
  render(width: number): string[];
@@ -73,3 +95,4 @@ export interface PaneOverlayComponent {
73
95
  dispose(): void;
74
96
  }
75
97
  export declare function paneOverlay<T = undefined, Row = unknown>(options: PaneOverlayOptions<T, Row>): FullscreenComponentFactory<T>;
98
+ export {};
@@ -1,5 +1,5 @@
1
1
  import { dispatchNavKeys } from "./key-dispatch.js";
2
- import { computeFixedSidebarLayout, computeSplitPaneLayout, endCursor, endScrollOffset, ensureCursorVisible, homeCursor, homeScrollOffset, moveCursor, moveScrollOffset, pageCursor, pageScrollOffset, resizeSplitPane, } from "./state.js";
2
+ import { computeFixedSidebarLayout, computeSplitPaneLayout, endScrollOffset, ensureCursorVisible, homeScrollOffset, moveScrollOffset, pageScrollOffset, resizeSplitPane, } from "./state.js";
3
3
  import { boxRow, clipStyled, flatRule, formatScrollInfo, padRight, renderKeyRow, titledBottomSegment, titledTopSegment, } from "./chrome.js";
4
4
  import { matchesKey } from "@earendil-works/pi-tui";
5
5
  const DEFAULT_CLOSE_KEYS = ["escape", "ctrl+c", "q"];
@@ -28,6 +28,51 @@ function defaultRenderRow(row) {
28
28
  function decorateRow(text, theme) {
29
29
  return theme.bold ? theme.bold(theme.fg("accent", text)) : theme.fg("accent", text);
30
30
  }
31
+ function isSeparatorRow(row) {
32
+ return typeof row === "object" && row !== null && row.kind === "separator";
33
+ }
34
+ function selectableIndexes(rows) {
35
+ const indexes = [];
36
+ for (let index = 0; index < rows.length; index++) {
37
+ if (!isSeparatorRow(rows[index]))
38
+ indexes.push(index);
39
+ }
40
+ return indexes;
41
+ }
42
+ function nearestSelectableIndex(rows, start) {
43
+ if (rows.length === 0)
44
+ return 0;
45
+ const clamped = Math.max(0, Math.min(start, rows.length - 1));
46
+ if (!isSeparatorRow(rows[clamped]))
47
+ return clamped;
48
+ for (let offset = 1; offset < rows.length; offset++) {
49
+ const after = clamped + offset;
50
+ if (after < rows.length && !isSeparatorRow(rows[after]))
51
+ return after;
52
+ const before = clamped - offset;
53
+ if (before >= 0 && !isSeparatorRow(rows[before]))
54
+ return before;
55
+ }
56
+ return 0;
57
+ }
58
+ function titleOptions(title, focused) {
59
+ if (typeof title === "object" && title !== null) {
60
+ return {
61
+ label: title.label,
62
+ tail: title.tail,
63
+ tailRendered: title.tailRendered,
64
+ tailPlain: title.tailPlain,
65
+ labelColor: title.labelColor ?? (focused ? "accent" : "text"),
66
+ tailColor: title.tailColor,
67
+ labelBold: title.labelBold ?? focused,
68
+ };
69
+ }
70
+ return {
71
+ label: title ?? "",
72
+ labelColor: focused ? "accent" : "text",
73
+ labelBold: focused,
74
+ };
75
+ }
31
76
  export function paneOverlay(options) {
32
77
  const split = options.split ?? {};
33
78
  const closeKeys = options.closeKeys ?? DEFAULT_CLOSE_KEYS;
@@ -47,6 +92,7 @@ export function paneOverlay(options) {
47
92
  let leftFraction = split.initialFraction ?? 0.5;
48
93
  let lastRenderWidth = 80;
49
94
  let lastSelectedKey;
95
+ let initialSelectionApplied = false;
50
96
  const primaryState = { cursor: 0, scrollOffset: 0 };
51
97
  const detailState = { scrollOffset: 0, sticky: stickyBottom };
52
98
  const perSelectionOffsets = new Map();
@@ -59,7 +105,7 @@ export function paneOverlay(options) {
59
105
  finished = true;
60
106
  done(result);
61
107
  };
62
- const makeContext = (selectedIndex, selectedRow, selectedKey, primaryRows, detailRows) => ({
108
+ const makeContext = (selectedIndex, selectedRow, selectedKey, primaryWidth = 0, detailWidth = 0) => ({
63
109
  tui,
64
110
  primaryFocus: focus === "primary",
65
111
  detailFocus: focus === "detail",
@@ -70,8 +116,9 @@ export function paneOverlay(options) {
70
116
  mode: options.primary.mode ?? "scroll",
71
117
  cursor: primaryState.cursor,
72
118
  scrollOffset: primaryState.scrollOffset,
119
+ width: primaryWidth,
73
120
  },
74
- detail: { scrollOffset: detailState.scrollOffset },
121
+ detail: { scrollOffset: detailState.scrollOffset, width: detailWidth },
75
122
  close: (result) => finish(result),
76
123
  requestRender,
77
124
  });
@@ -97,6 +144,60 @@ export function paneOverlay(options) {
97
144
  fractionBasis: split.fractionBasis,
98
145
  });
99
146
  };
147
+ const currentWidths = () => {
148
+ const layout = getLayout(lastRenderWidth);
149
+ return { primaryWidth: layout.leftWidth, detailWidth: layout.rightWidth };
150
+ };
151
+ const getPrimaryRows = (ctx) => {
152
+ return resolveValue(options.primary.rows, ctx) ?? [];
153
+ };
154
+ const selectionKeyFor = (row, index) => {
155
+ return (options.primary.selectionKey ?? ((_row, rowIndex) => String(rowIndex)))(row, index);
156
+ };
157
+ const applyInitialSelection = (rows) => {
158
+ if (initialSelectionApplied || rows.length === 0)
159
+ return;
160
+ let targetIndex = -1;
161
+ if (options.primary.initialSelectionKey !== undefined) {
162
+ targetIndex = rows.findIndex((row, index) => !isSeparatorRow(row) && selectionKeyFor(row, index) === options.primary.initialSelectionKey);
163
+ }
164
+ if (targetIndex < 0 && options.primary.initialIndex !== undefined) {
165
+ targetIndex = options.primary.initialIndex;
166
+ }
167
+ if (targetIndex < 0)
168
+ return;
169
+ initialSelectionApplied = true;
170
+ const selectableIndex = nearestSelectableIndex(rows, targetIndex);
171
+ primaryState.cursor = selectableIndex;
172
+ primaryState.scrollOffset = selectableIndex;
173
+ };
174
+ const computeSelectionFromRows = (primaryRows, bodyHeight) => {
175
+ applyInitialSelection(primaryRows);
176
+ const primaryMode = options.primary.mode ?? "scroll";
177
+ let selectedIndex = 0;
178
+ if (primaryMode === "cursor") {
179
+ const viewport = ensureCursorVisible({
180
+ cursor: nearestSelectableIndex(primaryRows, primaryState.cursor),
181
+ scroll: primaryState.scrollOffset,
182
+ itemCount: primaryRows.length,
183
+ viewportHeight: Math.max(1, bodyHeight),
184
+ });
185
+ primaryState.cursor = nearestSelectableIndex(primaryRows, viewport.cursor);
186
+ primaryState.scrollOffset = viewport.scroll;
187
+ selectedIndex = primaryState.cursor;
188
+ }
189
+ else {
190
+ primaryState.scrollOffset = Math.min(primaryState.scrollOffset, Math.max(0, primaryRows.length - 1));
191
+ selectedIndex = nearestSelectableIndex(primaryRows, primaryState.scrollOffset);
192
+ primaryState.scrollOffset = selectedIndex;
193
+ }
194
+ if (primaryRows.length === 0 || selectableIndexes(primaryRows).length === 0)
195
+ selectedIndex = 0;
196
+ const selectedCandidate = primaryRows[selectedIndex];
197
+ const selectedRow = !isSeparatorRow(selectedCandidate) ? selectedCandidate : undefined;
198
+ const selectedKey = selectedRow !== undefined ? selectionKeyFor(selectedRow, selectedIndex) : String(selectedIndex);
199
+ return { primaryRows, selectedIndex, selectedRow, selectedKey };
200
+ };
100
201
  const resize = (direction) => {
101
202
  if (collapsed)
102
203
  return;
@@ -129,16 +230,27 @@ export function paneOverlay(options) {
129
230
  }
130
231
  return state;
131
232
  };
132
- const listViewport = (bodyHeight, legendLineCount) => {
133
- const primaryHeight = legendPlacement === "primary"
134
- ? Math.max(0, bodyHeight - legendLineCount - 1)
135
- : bodyHeight;
136
- return { primaryHeight, detailHeight: bodyHeight };
233
+ const primarySections = (bodyHeight, legendLineCount, infoLineCount) => {
234
+ const legendBlockHeight = legendPlacement === "primary" && legendLineCount > 0 ? legendLineCount + 1 : 0;
235
+ const availableBeforeLegend = Math.max(0, bodyHeight - legendBlockHeight);
236
+ const infoVisibleCount = infoLineCount > 0
237
+ ? Math.min(infoLineCount, Math.max(0, availableBeforeLegend - 2))
238
+ : 0;
239
+ const infoBlockHeight = infoVisibleCount > 0 ? infoVisibleCount + 1 : 0;
240
+ return {
241
+ primaryHeight: Math.max(0, availableBeforeLegend - infoBlockHeight),
242
+ detailHeight: bodyHeight,
243
+ infoVisibleCount,
244
+ };
137
245
  };
138
246
  const pageSizeHalf = (height) => Math.max(1, Math.floor(height / 2));
139
247
  const withPrimaryViewport = (bodyHeight, legendLineCount) => {
140
- const primaryRows = resolveValue(options.primary.rows, makeContext(0, undefined, "", [], []));
141
- const viewportHeight = listViewport(bodyHeight, legendLineCount).primaryHeight;
248
+ const { primaryWidth, detailWidth } = currentWidths();
249
+ const primaryRows = getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth));
250
+ const selection = computeSelectionFromRows(primaryRows, bodyHeight);
251
+ const ctx = makeContext(selection.selectedIndex, selection.selectedRow, selection.selectedKey, primaryWidth, detailWidth);
252
+ const infoLines = resolveValue(options.primary.info, ctx) ?? [];
253
+ const viewportHeight = primarySections(bodyHeight, legendLineCount, infoLines.length).primaryHeight;
142
254
  return { primaryRows, viewportHeight: Math.max(1, viewportHeight) };
143
255
  };
144
256
  const applyPrimaryNav = (bodyHeight, legendLineCount, mutator) => {
@@ -148,8 +260,13 @@ export function paneOverlay(options) {
148
260
  const movePrimary = (delta, bodyHeight, legendLineCount) => {
149
261
  applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
150
262
  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;
263
+ const indexes = selectableIndexes(rows);
264
+ if (indexes.length === 0)
265
+ return;
266
+ const currentIndex = nearestSelectableIndex(rows, state.cursor);
267
+ const currentOrdinal = Math.max(0, indexes.indexOf(currentIndex));
268
+ state.cursor = indexes[Math.max(0, Math.min(indexes.length - 1, currentOrdinal + delta))] ?? currentIndex;
269
+ const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
153
270
  state.scrollOffset = next.scroll;
154
271
  }
155
272
  else {
@@ -161,8 +278,13 @@ export function paneOverlay(options) {
161
278
  applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
162
279
  const pageSize = pageSizeHalf(viewportHeight);
163
280
  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;
281
+ const indexes = selectableIndexes(rows);
282
+ if (indexes.length === 0)
283
+ return;
284
+ const currentIndex = nearestSelectableIndex(rows, state.cursor);
285
+ const currentOrdinal = Math.max(0, indexes.indexOf(currentIndex));
286
+ state.cursor = indexes[Math.max(0, Math.min(indexes.length - 1, currentOrdinal + delta * pageSize))] ?? currentIndex;
287
+ const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
166
288
  state.scrollOffset = next.scroll;
167
289
  }
168
290
  else {
@@ -173,7 +295,10 @@ export function paneOverlay(options) {
173
295
  const homePrimary = (bodyHeight, legendLineCount) => {
174
296
  applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
175
297
  if ((options.primary.mode ?? "scroll") === "cursor") {
176
- const next = homeCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
298
+ const indexes = selectableIndexes(rows);
299
+ if (indexes.length === 0)
300
+ return;
301
+ const next = ensureCursorVisible({ cursor: indexes[0] ?? 0, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
177
302
  state.cursor = next.cursor;
178
303
  state.scrollOffset = next.scroll;
179
304
  }
@@ -185,7 +310,10 @@ export function paneOverlay(options) {
185
310
  const endPrimary = (bodyHeight, legendLineCount) => {
186
311
  applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
187
312
  if ((options.primary.mode ?? "scroll") === "cursor") {
188
- const next = endCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
313
+ const indexes = selectableIndexes(rows);
314
+ if (indexes.length === 0)
315
+ return;
316
+ const next = ensureCursorVisible({ cursor: indexes[indexes.length - 1] ?? 0, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
189
317
  state.cursor = next.cursor;
190
318
  state.scrollOffset = next.scroll;
191
319
  }
@@ -229,29 +357,6 @@ export function paneOverlay(options) {
229
357
  const keyWidth = Math.min(11, Math.max(3, Math.floor(totalWidth / 4)));
230
358
  return entries.map((entry) => chromeTheme.fg("dim", renderKeyRow(entry.key, entry.label, totalWidth, keyWidth)));
231
359
  };
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
360
  const component = {
256
361
  dispose() {
257
362
  // no-op is acceptable
@@ -263,9 +368,11 @@ export function paneOverlay(options) {
263
368
  const layout = getLayout(totalWidth);
264
369
  const primaryWidth = layout.leftWidth;
265
370
  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);
371
+ const primaryRows = getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth));
372
+ const { selectedIndex, selectedRow, selectedKey } = computeSelectionFromRows(primaryRows, bodyHeight);
373
+ const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryWidth, detailWidth);
374
+ options.onRender?.(ctx);
375
+ const detailRows = options.detail.rows(ctx) ?? [];
269
376
  if (selectedKey !== lastSelectedKey) {
270
377
  lastSelectedKey = selectedKey;
271
378
  if (options.primary.onSelectionChange) {
@@ -283,7 +390,9 @@ export function paneOverlay(options) {
283
390
  ensureFocusDetailWhenCollapsed();
284
391
  const entries = legendEntries(ctx);
285
392
  const legendLines = renderLegendLines(entries, totalWidth);
286
- const { primaryHeight, detailHeight } = listViewport(bodyHeight, legendLines.length);
393
+ const infoLines = resolveValue(options.primary.info, ctx) ?? [];
394
+ const infoTitle = resolveValue(options.primary.infoTitle, ctx) ?? "info";
395
+ const { primaryHeight, detailHeight, infoVisibleCount } = primarySections(bodyHeight, legendLines.length, infoLines.length);
287
396
  // Align primary viewport.
288
397
  if ((options.primary.mode ?? "scroll") === "cursor") {
289
398
  const viewport = ensureCursorVisible({
@@ -315,15 +424,11 @@ export function paneOverlay(options) {
315
424
  const detailTitle = resolveValue(options.detail.title, ctx);
316
425
  const topPrimary = titledTopSegment(chromeTheme, {
317
426
  width: primaryWidth,
318
- label: primaryTitle ?? "",
319
- labelColor: focus === "primary" ? "accent" : "text",
320
- labelBold: focus === "primary",
427
+ ...titleOptions(primaryTitle, focus === "primary"),
321
428
  });
322
429
  const topDetail = titledTopSegment(chromeTheme, {
323
430
  width: detailWidth,
324
- label: detailTitle ?? "",
325
- labelColor: focus === "detail" ? "accent" : "text",
326
- labelBold: focus === "detail",
431
+ ...titleOptions(detailTitle, focus === "detail"),
327
432
  });
328
433
  const corner = (glyph) => chromeTheme.fg("dim", glyph);
329
434
  const topBorder = primaryWidth === 0
@@ -334,14 +439,17 @@ export function paneOverlay(options) {
334
439
  .slice(primaryState.scrollOffset, primaryState.scrollOffset + primaryHeight)
335
440
  .map((row, index) => {
336
441
  const absoluteIndex = primaryState.scrollOffset + index;
337
- const text = renderFn(row, ctx);
442
+ if (isSeparatorRow(row))
443
+ return flatRule(chromeTheme, row.label ?? "", primaryWidth);
444
+ const text = renderFn(row, ctx, primaryWidth);
338
445
  const selected = (options.primary.mode ?? "scroll") === "cursor" && absoluteIndex === primaryState.cursor && focus === "primary";
339
446
  return selected ? decorateRow(text, chromeTheme) : text;
340
447
  });
341
448
  const detailVisible = detailRows.slice(detailState.scrollOffset, detailState.scrollOffset + detailHeight);
449
+ const selectable = selectableIndexes(primaryRows);
342
450
  const primaryFooterText = resolveValue(options.primary.footer, ctx)
343
- ?? ((options.primary.mode ?? "scroll") === "cursor" && primaryRows.length > 0
344
- ? `${primaryState.cursor + 1}/${primaryRows.length}`
451
+ ?? ((options.primary.mode ?? "scroll") === "cursor" && selectable.length > 0
452
+ ? `${Math.max(0, selectable.indexOf(primaryState.cursor)) + 1}/${selectable.length}`
345
453
  : primaryRows.length > primaryHeight
346
454
  ? formatScrollInfo(primaryState.scrollOffset, Math.max(0, primaryRows.length - primaryHeight), { style: "position" })
347
455
  : "");
@@ -355,13 +463,21 @@ export function paneOverlay(options) {
355
463
  ? corner("╰") + bottomDetail + corner("╯")
356
464
  : corner("╰") + bottomPrimary + corner("┴") + bottomDetail + corner("╯");
357
465
  const bodyLines = [];
466
+ const infoStart = primaryHeight;
467
+ const legendStart = primaryHeight + (infoVisibleCount > 0 ? infoVisibleCount + 1 : 0);
358
468
  for (let row = 0; row < bodyHeight; row++) {
359
469
  let primaryCell = primaryVisible[row] ?? "";
360
- if (legendPlacement === "primary" && row === primaryHeight && legendLines.length > 0) {
470
+ if (infoVisibleCount > 0 && row === infoStart) {
471
+ primaryCell = flatRule(chromeTheme, infoTitle, primaryWidth);
472
+ }
473
+ else if (infoVisibleCount > 0 && row > infoStart && row < legendStart) {
474
+ primaryCell = infoLines[row - infoStart - 1] ?? "";
475
+ }
476
+ else if (legendPlacement === "primary" && row === legendStart && legendLines.length > 0) {
361
477
  primaryCell = flatRule(chromeTheme, collapse?.label ?? "actions", primaryWidth);
362
478
  }
363
- if (legendPlacement === "primary" && row > primaryHeight) {
364
- const legendIndex = row - primaryHeight - 1;
479
+ if (legendPlacement === "primary" && row > legendStart) {
480
+ const legendIndex = row - legendStart - 1;
365
481
  primaryCell = legendIndex < legendLines.length ? padRight(legendLines[legendIndex], primaryWidth) : "";
366
482
  }
367
483
  const detailCell = detailVisible[row] ?? "";
@@ -387,9 +503,11 @@ export function paneOverlay(options) {
387
503
  finish(options.closeResult);
388
504
  return;
389
505
  }
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);
506
+ const { primaryWidth, detailWidth } = currentWidths();
507
+ const primaryRows = getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth));
508
+ const { selectedIndex, selectedRow, selectedKey } = computeSelectionFromRows(primaryRows, bodyHeight);
509
+ const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryWidth, detailWidth);
510
+ const detailRows = options.detail.rows(ctx) ?? [];
393
511
  for (const action of customActions) {
394
512
  if (action.when && !action.when(ctx))
395
513
  continue;
package/docs/client.md CHANGED
@@ -77,12 +77,12 @@ const log = createLogger("my-extension", {
77
77
  });
78
78
 
79
79
  log.debug("hidden at info level");
80
- log.info("started");
81
- log.warn("slow path");
80
+ log.info("started", { command: "example" });
81
+ log.warn("slow path", { durationMs: 1200 });
82
82
  log.error("failed");
83
83
  ```
84
84
 
85
- `createLogger(name)` writes JSONL to `getAgentDir()/log/<name>.jsonl` by default. Each line is `{"ts":"...","level":"info","message":"started"}`. `level` is typed as `"debug" | "info" | "warn" | "error" | "silent"`; `maxFiles` controls retained rotations (`.1`, `.2`, ...). If `level`, `maxFiles`, or `maxBytes` are omitted, the value comes from `getAgentDir()/config/utils.jsonc` `logging` defaults. Explicit options always win.
85
+ `createLogger(name)` writes JSONL to `getAgentDir()/log/<name>.jsonl` by default. Each line is a flat object like `{"ts":"...","level":"info","message":"started","command":"example"}`. `ts`, `level`, and `message` are logger-owned fields and caller fields with those names are ignored. `level` is typed as `"debug" | "info" | "warn" | "error" | "silent"`; `maxFiles` controls retained rotations (`.1`, `.2`, ...). If `level`, `maxFiles`, or `maxBytes` are omitted, the value comes from `getAgentDir()/config/utils.jsonc` `logging` defaults. Explicit options always win.
86
86
 
87
87
  Override when needed:
88
88
 
@@ -19,12 +19,16 @@ await client.ui.fullscreen(
19
19
  mode: "cursor",
20
20
  rows: () => runs,
21
21
  selectionKey: (run) => run.id,
22
- renderRow: (run) => run.label,
22
+ initialSelectionKey: currentRunId,
23
+ renderRow: (run, ctx, width) => width > 40 ? `${run.label} ${run.status}` : run.label,
24
+ infoTitle: "selected",
25
+ info: (ctx) => ctx.selectedRow ? [`status: ${ctx.selectedRow.status}`] : [],
23
26
  },
24
27
  detail: {
25
- title: (ctx) => ctx.selectedRow?.label ?? "Details",
28
+ title: (ctx) => ({ label: ctx.selectedRow?.label ?? "Details", tail: "VAL" }),
26
29
  rows: (ctx) => renderDetails(ctx.selectedRow),
27
30
  },
31
+ onRender: (ctx) => tickFlashMessage(),
28
32
  customActions: [
29
33
  {
30
34
  keys: ["enter", "o"],
@@ -62,6 +66,29 @@ await client.ui.fullscreen(
62
66
  | `collapse` | Optional primary/sidebar collapse key |
63
67
  | `perSelectionScroll` | Keep separate detail scroll per selected key |
64
68
  | `stickyBottom` | Detail starts/follows at bottom until user scrolls |
69
+ | `onRender` | Per-frame hook for external transient UI state |
70
+
71
+ ## Primary pane extras
72
+
73
+ The primary pane can render more than a flat selectable list:
74
+
75
+ - `renderRow(row, ctx, width)` receives the current primary pane width. The same value is also exposed as `ctx.primary.width`; `ctx.detail.width` exposes the detail pane width.
76
+ - `primary.info` renders a selected-row-derived block below the list and above a primary-placed legend. Use `primary.infoTitle` to label its divider.
77
+ - `rows` may include `{ kind: "separator", label: "done" }`. Separator rows render as inline rules and are skipped by cursor movement and selection keys.
78
+ - `initialSelectionKey` or `initialIndex` controls the first selected row when the overlay opens. `initialSelectionKey` applies on the first frame where that key is present, including async-arriving rows.
79
+
80
+ Pane titles may be strings or structured objects forwarded to `titledTopSegment`:
81
+
82
+ ```ts
83
+ detail: {
84
+ title: (ctx) => ({
85
+ label: ctx.selectedRow?.name ?? "Details",
86
+ tailRendered: renderStatusTail(ctx.selectedRow),
87
+ tailPlain: plainStatusTail(ctx.selectedRow),
88
+ }),
89
+ rows: renderDetailRows,
90
+ }
91
+ ```
65
92
 
66
93
  ## Custom actions
67
94
 
@@ -8,7 +8,7 @@ const log = createLogger("example-extension", {
8
8
 
9
9
  export default function (pi: ExtensionAPI) {
10
10
  pi.on("session_start", (_event, ctx) => {
11
- log.info(`session started cwd=${ctx.cwd}`);
11
+ log.info("session started", { cwd: ctx.cwd });
12
12
  });
13
13
 
14
14
  pi.registerCommand("example-log", {
@@ -4,12 +4,13 @@ import { connect, paneOverlay } from "pi-extension-utils";
4
4
  interface RunRow {
5
5
  id: string;
6
6
  label: string;
7
+ status: string;
7
8
  lines: string[];
8
9
  }
9
10
 
10
11
  const runs: RunRow[] = [
11
- { id: "a", label: "explorer", lines: ["status: running", "task: inspect files"] },
12
- { id: "b", label: "qa", lines: ["status: done", "tests: passed"] },
12
+ { id: "a", label: "explorer", status: "running", lines: ["status: running", "task: inspect files"] },
13
+ { id: "b", label: "qa", status: "done", lines: ["status: done", "tests: passed"] },
13
14
  ];
14
15
 
15
16
  export default function (pi: ExtensionAPI) {
@@ -25,10 +26,13 @@ export default function (pi: ExtensionAPI) {
25
26
  mode: "cursor",
26
27
  rows: runs,
27
28
  selectionKey: (run) => run.id,
28
- renderRow: (run) => run.label,
29
+ initialSelectionKey: "b",
30
+ renderRow: (run, _overlay, width) => width > 28 ? `${run.label} ${run.status}` : run.label,
31
+ infoTitle: "selected",
32
+ info: (overlay) => overlay.selectedRow ? [`status: ${overlay.selectedRow.status}`] : [],
29
33
  },
30
34
  detail: {
31
- title: (overlay) => overlay.selectedRow?.label ?? "Details",
35
+ title: (overlay) => ({ label: overlay.selectedRow?.label ?? "Details", tail: overlay.selectedRow?.status }),
32
36
  rows: (overlay) => overlay.selectedRow?.lines ?? ["No selection"],
33
37
  },
34
38
  split: { initialFraction: 0.35, minPrimaryWidth: 20, minDetailWidth: 30 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extension-utils",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared Pi extension utilities for coordinated widgets, fullscreen leases, and logging.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",