@xynogen/pix-runtime 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -10,7 +10,7 @@ See `DESIGN.md` for the full contract.
10
10
  ## What it does
11
11
 
12
12
  - Versioned, sparse `pix.json` (`$version: 1`) — defaults resolve in code.
13
- - Typed sections: `collapse`, `pretty`, `optimizer`, `gate`.
13
+ - Typed sections: `collapse`, `pretty`, `io`, `optimizer`, `gate`.
14
14
  - Atomic writes behind a serialized in-process queue and a short-lived
15
15
  cross-process lock. A failed write leaves the old file intact.
16
16
  - Immutable, deeply frozen config snapshots with a monotonic revision.
@@ -38,8 +38,17 @@ import { prettySection } from "@xynogen/pix-runtime/sections";
38
38
  const icons = config(prettySection).icons; // synchronous read
39
39
  await updateConfig(prettySection, { icons: "ascii" });
40
40
  const off = onConfigChange((c) => render(), { paths: ["pretty.icons"] });
41
+
42
+ import { ioTimeoutMs, ioTimeoutSignal } from "@xynogen/pix-runtime/io";
43
+ const timeoutMs = ioTimeoutMs(); // shared network timeout
44
+ const signal = ioTimeoutSignal(toolSignal); // timeout + cancellation
41
45
  ```
42
46
 
47
+ Set `io.timeoutSec` in `~/.pi/agent/pix.json`, or change **Network → timeout (sec)**
48
+ with `/pix`. The default is 30 seconds. It applies to Pix network operations,
49
+ including remote skills, web fetch/search/transcription, MCP requests and
50
+ connection bootstrap, background model-data refreshes, and update downloads.
51
+
43
52
  Collapse policy helpers:
44
53
 
45
54
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",
@@ -24,6 +24,7 @@
24
24
  "./config": "./src/runtime.ts",
25
25
  "./sections": "./src/sections/index.ts",
26
26
  "./collapse": "./src/collapse.ts",
27
+ "./io": "./src/io.ts",
27
28
  "./once": "./src/once.ts",
28
29
  "./testing": "./src/testing.ts"
29
30
  },
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export type {
7
7
  SubscribeOptions,
8
8
  } from "./events.ts";
9
9
  export { default } from "./extension.ts";
10
+ export { ioTimeoutMs, ioTimeoutSignal } from "./io.ts";
10
11
  export {
11
12
  config,
12
13
  createRuntime,
@@ -34,6 +35,8 @@ export {
34
35
  type GateConfig,
35
36
  type GateRuleConfig,
36
37
  gateSection,
38
+ type IoConfig,
39
+ ioSection,
37
40
  type OptimizerConfig,
38
41
  optimizerSection,
39
42
  type PrettyConfig,
package/src/io.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { config } from "./runtime.ts";
2
+ import { ioSection } from "./sections/io.ts";
3
+
4
+ export function ioTimeoutMs(): number {
5
+ return config(ioSection).timeoutSec * 1000;
6
+ }
7
+
8
+ export function ioTimeoutSignal(signal?: AbortSignal): AbortSignal {
9
+ const timeout = AbortSignal.timeout(ioTimeoutMs());
10
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
11
+ }
@@ -7,11 +7,19 @@
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";
14
21
  import { gateSection } from "./sections/gate.ts";
22
+ import { ioSection } from "./sections/io.ts";
15
23
  import { prettySection } from "./sections/pretty.ts";
16
24
 
17
25
  interface SettingRow<T> {
@@ -61,6 +69,14 @@ const SETTINGS: SettingRow<unknown>[] = [
61
69
  read: (v) => String(v.delaySec),
62
70
  patch: (value) => ({ delaySec: Number(value) }),
63
71
  }),
72
+ row({
73
+ section: "Network",
74
+ label: "timeout (sec)",
75
+ handle: ioSection,
76
+ values: ["30", "10", "60", "120", "300"],
77
+ read: (v) => String(v.timeoutSec),
78
+ patch: (value) => ({ timeoutSec: Number(value) }),
79
+ }),
64
80
  row({
65
81
  section: "Gate",
66
82
  label: "Guardrails",
@@ -104,16 +120,20 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
104
120
  const boxW = 52;
105
121
  await ui.custom(
106
122
  (
107
- tui: { requestRender(): void },
123
+ tui: { requestRender(): void; terminal?: { rows?: number } },
108
124
  theme: {
109
125
  fg(c: string, t: string): string;
110
126
  bg(c: string, t: string): string;
111
127
  bold(t: string): string;
112
128
  },
113
- _kb: unknown,
129
+ kb: KeybindingsManager,
114
130
  done: (v: null) => void,
115
131
  ) => {
116
132
  let selected = 0;
133
+ let bodyOffset = 0;
134
+ let visibleBodyLines = 1;
135
+ let maxBodyOffset = 0;
136
+ let inspectingPage = false;
117
137
 
118
138
  const cycle = (direction: -1 | 1) => {
119
139
  const row = SETTINGS[selected];
@@ -126,18 +146,20 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
126
146
  };
127
147
  const move = (direction: -1 | 1) => {
128
148
  selected = (selected + direction + SETTINGS.length) % SETTINGS.length;
149
+ inspectingPage = false;
129
150
  };
130
151
 
131
152
  return {
132
153
  render: () => {
133
154
  const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
134
- const lines: string[] = [theme.fg("accent", theme.bold(" pix settings")), ""];
155
+ const body: string[] = [];
156
+ const settingBodyLines: number[] = [];
135
157
  let lastSection = "";
136
158
  for (let i = 0; i < SETTINGS.length; i++) {
137
159
  const row = SETTINGS[i]!;
138
160
  if (row.section !== lastSection) {
139
- if (lastSection) lines.push("");
140
- lines.push(theme.fg("dim", ` ${row.section}`));
161
+ if (lastSection) body.push("");
162
+ body.push(theme.fg("dim", ` ${row.section}`));
141
163
  lastSection = row.section;
142
164
  }
143
165
  const sel = i === selected;
@@ -145,19 +167,40 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
145
167
  const label = theme.fg(sel ? "accent" : "text", row.label.padEnd(labelW));
146
168
  const value = row.read(runtime.get(row.handle));
147
169
  const isDefault = value === row.values[0];
148
- lines.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
170
+ settingBodyLines[i] = body.length;
171
+ body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
149
172
  }
150
- lines.push("");
151
- lines.push(theme.fg("dim", "←→ change · ↑↓ move · esc close"));
152
- return frameLines({
173
+ const result = frameModal({
153
174
  width: boxW,
154
- lines,
175
+ maxHeight: modalHeight(tui.terminal?.rows),
176
+ header: [theme.fg("accent", theme.bold(" pix settings")), ""],
177
+ body,
178
+ footer: [
179
+ "",
180
+ theme.fg("dim", "←→ change · ↑↓ move · PgUp/PgDn inspect · esc close"),
181
+ ],
182
+ bodyOffset,
183
+ selectedBodyLine: inspectingPage ? undefined : settingBodyLines[selected],
155
184
  color: (s: string) => theme.fg("accent", s),
156
185
  bg: (s: string) => theme.bg("customMessageBg", s),
157
186
  });
187
+ bodyOffset = result.bodyOffset;
188
+ visibleBodyLines = Math.max(1, result.visibleBodyLines);
189
+ maxBodyOffset = result.maxBodyOffset;
190
+ return result.lines;
158
191
  },
159
192
  invalidate: () => {},
160
193
  handleInput: (data: string) => {
194
+ const pageUp = kb?.matches(data, "tui.select.pageUp") || matchesKey(data, Key.pageUp);
195
+ const pageDown =
196
+ kb?.matches(data, "tui.select.pageDown") || matchesKey(data, Key.pageDown);
197
+ if (pageUp || pageDown) {
198
+ const direction = pageUp ? -1 : 1;
199
+ bodyOffset = pageBodyOffset(bodyOffset, visibleBodyLines, maxBodyOffset, direction);
200
+ inspectingPage = true;
201
+ tui.requestRender();
202
+ return;
203
+ }
161
204
  // matchesKey handles both legacy bytes and Kitty CSI-u encodings
162
205
  // for letters and special keys alike — raw string compares like
163
206
  // `data === "k"` silently fail under the Kitty keyboard protocol.
@@ -185,29 +228,96 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
185
228
  });
186
229
  }
187
230
 
188
- // ── Inline frameLines (runtime must not depend on pix-pretty — pretty depends on us) ─
231
+ // ── Inline modal engine (runtime cannot depend on pix-pretty: pretty depends on runtime) ─
189
232
 
190
- interface FrameOptions {
233
+ interface RuntimeModalOptions {
191
234
  width: number;
192
- lines: string[];
235
+ maxHeight: number;
236
+ header: string[];
237
+ body: string[];
238
+ footer: string[];
239
+ bodyOffset: number;
240
+ selectedBodyLine?: number;
193
241
  color: (s: string) => string;
194
242
  bg?: (s: string) => string;
195
243
  }
196
244
 
197
- function visibleWidth(s: string): number {
198
- // Strip ANSI escape sequences for width calculation.
199
- return s.replace(/\x1b\[[0-9;]*m/g, "").length;
245
+ interface RuntimeModalResult {
246
+ lines: string[];
247
+ bodyOffset: number;
248
+ visibleBodyLines: number;
249
+ maxBodyOffset: number;
250
+ }
251
+
252
+ function modalHeight(rows = 24): number {
253
+ const safe = Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : 24;
254
+ return Math.max(1, Math.min(safe, Math.floor(safe * 0.8)));
255
+ }
256
+
257
+ function pageBodyOffset(offset: number, size: number, max: number, direction: -1 | 1): number {
258
+ const s = Math.max(1, size);
259
+ const step = Math.max(1, Math.floor(s / 2));
260
+ return Math.min(max, Math.max(0, offset + direction * step));
261
+ }
262
+
263
+ function fitLines(lines: string[], width: number): string[] {
264
+ const rows: string[] = [];
265
+ for (const line of lines) {
266
+ if (line === "") rows.push("");
267
+ else rows.push(...wrapTextWithAnsi(line, Math.max(1, width)));
268
+ }
269
+ return rows;
200
270
  }
201
271
 
202
- /** Render a rounded bordered modal box (╭─╮╰─╯) with solid background fill. */
203
- function frameLines(opts: FrameOptions): string[] {
204
- const { width, lines, color } = opts;
272
+ function frameModal(opts: RuntimeModalOptions): RuntimeModalResult {
273
+ const inner = Math.max(1, opts.width - 4);
274
+ const header = fitLines(opts.header, inner);
275
+ const footer = fitLines(opts.footer, inner);
276
+ const body = fitLines(opts.body, inner);
277
+ const cap = Math.max(1, opts.maxHeight);
278
+ const bodyBudget = cap - 2 - header.length - footer.length;
279
+ const overflows = body.length > Math.max(0, bodyBudget);
280
+ const visibleBodyLines = overflows ? Math.max(0, bodyBudget - 1) : Math.max(0, bodyBudget);
281
+ if (visibleBodyLines === 0 && body.length > 0) {
282
+ return {
283
+ lines: frameRows(opts.width, ["Terminal too short — resize or press esc to cancel"], opts),
284
+ bodyOffset: 0,
285
+ visibleBodyLines: 0,
286
+ maxBodyOffset: 0,
287
+ };
288
+ }
289
+ const maxBodyOffset = Math.max(0, body.length - visibleBodyLines);
290
+ let offset = Math.min(maxBodyOffset, Math.max(0, opts.bodyOffset));
291
+ if (opts.selectedBodyLine !== undefined) {
292
+ const selected = Math.min(body.length - 1, Math.max(0, opts.selectedBodyLine));
293
+ if (selected < offset) offset = selected;
294
+ else if (selected >= offset + visibleBodyLines) offset = selected - visibleBodyLines + 1;
295
+ }
296
+ const end = Math.min(body.length, offset + visibleBodyLines);
297
+ const lines = [...header];
298
+ if (overflows) {
299
+ const step = Math.max(1, Math.floor(visibleBodyLines / 2));
300
+ const totalPages = Math.max(1, Math.ceil(maxBodyOffset / step) + 1);
301
+ const page = offset >= maxBodyOffset ? totalPages : Math.floor(offset / step) + 1;
302
+ lines.push(`PgUp/PgDn inspect • ${page}/${totalPages}`);
303
+ }
304
+ lines.push(...body.slice(offset, end), ...footer);
305
+ return {
306
+ lines: frameRows(opts.width, lines, opts),
307
+ bodyOffset: offset,
308
+ visibleBodyLines,
309
+ maxBodyOffset,
310
+ };
311
+ }
312
+
313
+ function frameRows(
314
+ width: number,
315
+ lines: string[],
316
+ opts: Pick<RuntimeModalOptions, "color" | "bg">,
317
+ ): string[] {
205
318
  const bg = opts.bg ?? ((s: string) => s);
206
- const inner = Math.max(1, width - 4); // 2 border + 2 padding
319
+ const inner = Math.max(1, width - 4);
207
320
  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
321
  const SENTINEL = "\x00";
212
322
  const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
213
323
  const reassert = (s: string): string =>
@@ -216,15 +326,10 @@ function frameLines(opts: FrameOptions): string[] {
216
326
  p === "0" || p.split(";").includes("49") ? `${seq}${bgOpen}` : seq,
217
327
  )
218
328
  : s;
219
-
220
329
  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("│")}`);
330
+ const fitted = truncateToWidth(content, inner, "…");
331
+ const padded = fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted)));
332
+ return bg(`${opts.color("│")} ${reassert(padded)} ${opts.color("│")}`);
224
333
  };
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;
334
+ return [bg(opts.color(`╭${dashes}╮`)), ...lines.map(row), bg(opts.color(`╰${dashes}╯`))];
230
335
  }
@@ -5,6 +5,7 @@ export {
5
5
  type GateSeverity,
6
6
  gateSection,
7
7
  } from "./gate.ts";
8
+ export { type IoConfig, ioSection } from "./io.ts";
8
9
  export {
9
10
  type CavemanLevel,
10
11
  type OptimizerConfig,
@@ -22,6 +23,7 @@ export {
22
23
 
23
24
  import { collapseSection } from "./collapse.ts";
24
25
  import { gateSection } from "./gate.ts";
26
+ import { ioSection } from "./io.ts";
25
27
  import { optimizerSection } from "./optimizer.ts";
26
28
  import { prettySection } from "./pretty.ts";
27
29
 
@@ -29,6 +31,7 @@ import { prettySection } from "./pretty.ts";
29
31
  export const builtinSections = [
30
32
  collapseSection,
31
33
  prettySection,
34
+ ioSection,
32
35
  optimizerSection,
33
36
  gateSection,
34
37
  ] as const;
@@ -0,0 +1,21 @@
1
+ import { defineSection, isObj, posNumOr } from "../schema.ts";
2
+
3
+ export interface IoConfig {
4
+ /** Default timeout in seconds for Pix network requests. */
5
+ timeoutSec: number;
6
+ }
7
+
8
+ const DEFAULTS: Readonly<IoConfig> = {
9
+ timeoutSec: 30,
10
+ };
11
+
12
+ export const ioSection = defineSection<"io", IoConfig>({
13
+ key: "io",
14
+ defaults: DEFAULTS,
15
+ parse(raw) {
16
+ if (!isObj(raw)) return { ...DEFAULTS };
17
+ return {
18
+ timeoutSec: posNumOr(raw.timeoutSec, DEFAULTS.timeoutSec),
19
+ };
20
+ },
21
+ });