@xynogen/pix-runtime 0.3.0 → 0.3.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/pix-command.ts +129 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -7,7 +7,14 @@
7
7
  */
8
8
 
9
9
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
- import { matchesKey } from "@earendil-works/pi-tui";
10
+ import {
11
+ Key,
12
+ type KeybindingsManager,
13
+ matchesKey,
14
+ truncateToWidth,
15
+ visibleWidth,
16
+ wrapTextWithAnsi,
17
+ } from "@earendil-works/pi-tui";
11
18
  import type { PixRuntime } from "./runtime.ts";
12
19
  import type { DeepPartial, SectionHandle } from "./schema.ts";
13
20
  import { collapseSection } from "./sections/collapse.ts";
@@ -104,16 +111,20 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
104
111
  const boxW = 52;
105
112
  await ui.custom(
106
113
  (
107
- tui: { requestRender(): void },
114
+ tui: { requestRender(): void; terminal?: { rows?: number } },
108
115
  theme: {
109
116
  fg(c: string, t: string): string;
110
117
  bg(c: string, t: string): string;
111
118
  bold(t: string): string;
112
119
  },
113
- _kb: unknown,
120
+ kb: KeybindingsManager,
114
121
  done: (v: null) => void,
115
122
  ) => {
116
123
  let selected = 0;
124
+ let bodyOffset = 0;
125
+ let visibleBodyLines = 1;
126
+ let maxBodyOffset = 0;
127
+ let inspectingPage = false;
117
128
 
118
129
  const cycle = (direction: -1 | 1) => {
119
130
  const row = SETTINGS[selected];
@@ -126,18 +137,20 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
126
137
  };
127
138
  const move = (direction: -1 | 1) => {
128
139
  selected = (selected + direction + SETTINGS.length) % SETTINGS.length;
140
+ inspectingPage = false;
129
141
  };
130
142
 
131
143
  return {
132
144
  render: () => {
133
145
  const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
134
- const lines: string[] = [theme.fg("accent", theme.bold(" pix settings")), ""];
146
+ const body: string[] = [];
147
+ const settingBodyLines: number[] = [];
135
148
  let lastSection = "";
136
149
  for (let i = 0; i < SETTINGS.length; i++) {
137
150
  const row = SETTINGS[i]!;
138
151
  if (row.section !== lastSection) {
139
- if (lastSection) lines.push("");
140
- lines.push(theme.fg("dim", ` ${row.section}`));
152
+ if (lastSection) body.push("");
153
+ body.push(theme.fg("dim", ` ${row.section}`));
141
154
  lastSection = row.section;
142
155
  }
143
156
  const sel = i === selected;
@@ -145,19 +158,40 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
145
158
  const label = theme.fg(sel ? "accent" : "text", row.label.padEnd(labelW));
146
159
  const value = row.read(runtime.get(row.handle));
147
160
  const isDefault = value === row.values[0];
148
- lines.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
161
+ settingBodyLines[i] = body.length;
162
+ body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
149
163
  }
150
- lines.push("");
151
- lines.push(theme.fg("dim", "←→ change · ↑↓ move · esc close"));
152
- return frameLines({
164
+ const result = frameModal({
153
165
  width: boxW,
154
- lines,
166
+ maxHeight: modalHeight(tui.terminal?.rows),
167
+ header: [theme.fg("accent", theme.bold(" pix settings")), ""],
168
+ body,
169
+ footer: [
170
+ "",
171
+ theme.fg("dim", "←→ change · ↑↓ move · PgUp/PgDn inspect · esc close"),
172
+ ],
173
+ bodyOffset,
174
+ selectedBodyLine: inspectingPage ? undefined : settingBodyLines[selected],
155
175
  color: (s: string) => theme.fg("accent", s),
156
176
  bg: (s: string) => theme.bg("customMessageBg", s),
157
177
  });
178
+ bodyOffset = result.bodyOffset;
179
+ visibleBodyLines = Math.max(1, result.visibleBodyLines);
180
+ maxBodyOffset = result.maxBodyOffset;
181
+ return result.lines;
158
182
  },
159
183
  invalidate: () => {},
160
184
  handleInput: (data: string) => {
185
+ const pageUp = kb?.matches(data, "tui.select.pageUp") || matchesKey(data, Key.pageUp);
186
+ const pageDown =
187
+ kb?.matches(data, "tui.select.pageDown") || matchesKey(data, Key.pageDown);
188
+ if (pageUp || pageDown) {
189
+ const direction = pageUp ? -1 : 1;
190
+ bodyOffset = pageBodyOffset(bodyOffset, visibleBodyLines, maxBodyOffset, direction);
191
+ inspectingPage = true;
192
+ tui.requestRender();
193
+ return;
194
+ }
161
195
  // matchesKey handles both legacy bytes and Kitty CSI-u encodings
162
196
  // for letters and special keys alike — raw string compares like
163
197
  // `data === "k"` silently fail under the Kitty keyboard protocol.
@@ -185,29 +219,96 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
185
219
  });
186
220
  }
187
221
 
188
- // ── Inline frameLines (runtime must not depend on pix-pretty — pretty depends on us) ─
222
+ // ── Inline modal engine (runtime cannot depend on pix-pretty: pretty depends on runtime) ─
189
223
 
190
- interface FrameOptions {
224
+ interface RuntimeModalOptions {
191
225
  width: number;
192
- lines: string[];
226
+ maxHeight: number;
227
+ header: string[];
228
+ body: string[];
229
+ footer: string[];
230
+ bodyOffset: number;
231
+ selectedBodyLine?: number;
193
232
  color: (s: string) => string;
194
233
  bg?: (s: string) => string;
195
234
  }
196
235
 
197
- function visibleWidth(s: string): number {
198
- // Strip ANSI escape sequences for width calculation.
199
- return s.replace(/\x1b\[[0-9;]*m/g, "").length;
236
+ interface RuntimeModalResult {
237
+ lines: string[];
238
+ bodyOffset: number;
239
+ visibleBodyLines: number;
240
+ maxBodyOffset: number;
241
+ }
242
+
243
+ function modalHeight(rows = 24): number {
244
+ const safe = Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : 24;
245
+ return Math.max(1, Math.min(safe, Math.floor(safe * 0.8)));
246
+ }
247
+
248
+ function pageBodyOffset(offset: number, size: number, max: number, direction: -1 | 1): number {
249
+ const s = Math.max(1, size);
250
+ const step = Math.max(1, Math.floor(s / 2));
251
+ return Math.min(max, Math.max(0, offset + direction * step));
252
+ }
253
+
254
+ function fitLines(lines: string[], width: number): string[] {
255
+ const rows: string[] = [];
256
+ for (const line of lines) {
257
+ if (line === "") rows.push("");
258
+ else rows.push(...wrapTextWithAnsi(line, Math.max(1, width)));
259
+ }
260
+ return rows;
261
+ }
262
+
263
+ function frameModal(opts: RuntimeModalOptions): RuntimeModalResult {
264
+ const inner = Math.max(1, opts.width - 4);
265
+ const header = fitLines(opts.header, inner);
266
+ const footer = fitLines(opts.footer, inner);
267
+ const body = fitLines(opts.body, inner);
268
+ const cap = Math.max(1, opts.maxHeight);
269
+ const bodyBudget = cap - 2 - header.length - footer.length;
270
+ const overflows = body.length > Math.max(0, bodyBudget);
271
+ const visibleBodyLines = overflows ? Math.max(0, bodyBudget - 1) : Math.max(0, bodyBudget);
272
+ if (visibleBodyLines === 0 && body.length > 0) {
273
+ return {
274
+ lines: frameRows(opts.width, ["Terminal too short — resize or press esc to cancel"], opts),
275
+ bodyOffset: 0,
276
+ visibleBodyLines: 0,
277
+ maxBodyOffset: 0,
278
+ };
279
+ }
280
+ const maxBodyOffset = Math.max(0, body.length - visibleBodyLines);
281
+ let offset = Math.min(maxBodyOffset, Math.max(0, opts.bodyOffset));
282
+ if (opts.selectedBodyLine !== undefined) {
283
+ const selected = Math.min(body.length - 1, Math.max(0, opts.selectedBodyLine));
284
+ if (selected < offset) offset = selected;
285
+ else if (selected >= offset + visibleBodyLines) offset = selected - visibleBodyLines + 1;
286
+ }
287
+ const end = Math.min(body.length, offset + visibleBodyLines);
288
+ const lines = [...header];
289
+ if (overflows) {
290
+ const step = Math.max(1, Math.floor(visibleBodyLines / 2));
291
+ const totalPages = Math.max(1, Math.ceil(maxBodyOffset / step) + 1);
292
+ const page = offset >= maxBodyOffset ? totalPages : Math.floor(offset / step) + 1;
293
+ lines.push(`PgUp/PgDn inspect • ${page}/${totalPages}`);
294
+ }
295
+ lines.push(...body.slice(offset, end), ...footer);
296
+ return {
297
+ lines: frameRows(opts.width, lines, opts),
298
+ bodyOffset: offset,
299
+ visibleBodyLines,
300
+ maxBodyOffset,
301
+ };
200
302
  }
201
303
 
202
- /** Render a rounded bordered modal box (╭─╮╰─╯) with solid background fill. */
203
- function frameLines(opts: FrameOptions): string[] {
204
- const { width, lines, color } = opts;
304
+ function frameRows(
305
+ width: number,
306
+ lines: string[],
307
+ opts: Pick<RuntimeModalOptions, "color" | "bg">,
308
+ ): string[] {
205
309
  const bg = opts.bg ?? ((s: string) => s);
206
- const inner = Math.max(1, width - 4); // 2 border + 2 padding
310
+ const inner = Math.max(1, width - 4);
207
311
  const dashes = "─".repeat(width - 2);
208
-
209
- // Re-assert the bg OPEN sequence after any full reset (\x1b[0m) or bg reset
210
- // (\x1b[49m) embedded in themed content, so the fill has no transparent holes.
211
312
  const SENTINEL = "\x00";
212
313
  const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
213
314
  const reassert = (s: string): string =>
@@ -216,15 +317,10 @@ function frameLines(opts: FrameOptions): string[] {
216
317
  p === "0" || p.split(";").includes("49") ? `${seq}${bgOpen}` : seq,
217
318
  )
218
319
  : s;
219
-
220
320
  const row = (content: string): string => {
221
- const pad = inner - visibleWidth(content);
222
- const padded = pad > 0 ? content + " ".repeat(pad) : content.slice(0, inner);
223
- return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
321
+ const fitted = truncateToWidth(content, inner, "…");
322
+ const padded = fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted)));
323
+ return bg(`${opts.color("│")} ${reassert(padded)} ${opts.color("│")}`);
224
324
  };
225
-
226
- const out: string[] = [bg(color(`╭${dashes}╮`))];
227
- for (const line of lines) out.push(row(line));
228
- out.push(bg(color(`╰${dashes}╯`)));
229
- return out;
325
+ return [bg(opts.color(`╭${dashes}╮`)), ...lines.map(row), bg(opts.color(`╰${dashes}╯`))];
230
326
  }