@oh-my-pi/pi-utils 18.5.0 → 18.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.5.1] - 2026-10-03
6
+
7
+ ### Added
8
+
9
+ - Added utilities for detecting and scanning own-line display-math blocks in growing text, including identifying possible openers and closers efficiently.
10
+ - Added an option to `TerminalQueryResponder` that lets PTY hosts provide cursor-position reports themselves.
11
+ - Added `refreshShellConfigCache()` to rebuild the cached shell spawn environment from the current process environment.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed the Markdown lexer dropping text preceding U+2028 or U+2029 line-separator characters.
16
+
5
17
  ## [18.5.0] - 2026-10-03
6
18
 
7
19
  ### Added
@@ -6,6 +6,8 @@
6
6
  */
7
7
  /** Opening delimiter. Each closer (`$`, `$$`, `\)`, `\]`) is as wide as its opener. */
8
8
  export type MathOpener = "$" | "$$" | "\\(" | "\\[";
9
+ /** Opener of an own-line display block. */
10
+ export type MathBlockOpener = "$$" | "\\[";
9
11
  /** A closed math span found in the source. */
10
12
  export interface MathSpan {
11
13
  opener: MathOpener;
@@ -65,3 +67,39 @@ export declare function mathSpanInContext(context: {
65
67
  }, src: string): MathSpan | undefined;
66
68
  /** The own-line display block starting at offset 0, or `undefined`. */
67
69
  export declare function mathBlockAt(source: string): MathBlock | undefined;
70
+ /**
71
+ * {@link mathBlockAt} for many offsets of one `source`. An own-line opener
72
+ * whose closer line is missing means every later opener of the same kind
73
+ * misses one too, since its search covers a suffix of that search. The scan
74
+ * keeps that answer, so asking at every block start takes linear time instead
75
+ * of a scan to the end of `source` per unclosed opener.
76
+ */
77
+ export declare class MathBlockScan {
78
+ #private;
79
+ constructor(source: string);
80
+ /** The own-line display block starting at `from`, or `undefined`. */
81
+ at(from: number): MathBlock | undefined;
82
+ }
83
+ /**
84
+ * The display opener alone on the line at `from` (up to 3 leading spaces,
85
+ * trailing spaces or tabs; the source's last line may be unterminated), or
86
+ * `undefined`.
87
+ */
88
+ export declare function mathBlockOpenerAt(source: string, from?: number): MathBlockOpener | undefined;
89
+ /**
90
+ * Whether the own-line display block opened at `from` in `source` is closed,
91
+ * or could still close once more text is appended: the text from `from`
92
+ * onward is a streaming prefix whose real closer may not have arrived. A
93
+ * closer on the unterminated last line has not arrived either, since the next
94
+ * append may extend it into text (`$$ E = mc^2 $$`). With no opener line at
95
+ * `from` it returns false before scanning, so a caller can ask at every block
96
+ * start.
97
+ */
98
+ export declare function mathBlockMayCloseAt(source: string, from?: number): boolean;
99
+ /**
100
+ * Offset of the first line of `source`, from the line start `from` on, that
101
+ * holds the closer of `opener` alone (up to 3 leading spaces, trailing spaces
102
+ * or tabs), the unterminated last line included, or `undefined`: the first
103
+ * line at which a block `opener` opened before `from` could close.
104
+ */
105
+ export declare function mathBlockCloserIndex(source: string, from: number, opener: MathBlockOpener): number | undefined;
@@ -70,6 +70,20 @@ export declare function resolveWindowsShell(env?: Record<string, string | undefi
70
70
  * `shellPath` mid-process immediately falls back to the platform default.
71
71
  */
72
72
  export declare function getShellConfig(customShellPath?: string, options?: ShellConfigOptions): ShellConfig;
73
+ /**
74
+ * Capture the current process environment for child shells and drop both cached
75
+ * shell configs, so the next {@link getShellConfig} builds the spawn environment
76
+ * from that capture. Call right after code that changed `process.env` in a way
77
+ * child shells must see, such as extension session lifecycle handlers exporting
78
+ * session-scoped variables; later writes reach child shells only at the next call.
79
+ */
80
+ export declare function refreshShellConfigCache(): void;
81
+ /**
82
+ * Test-only: forget the capture and both cached shell configs, so the next
83
+ * {@link getShellConfig} builds from the live environment again. Production code
84
+ * MUST NOT call this; the capture's lifecycle is owned by {@link refreshShellConfigCache}.
85
+ */
86
+ export declare function __resetShellConfigCacheForTests(): void;
73
87
  /**
74
88
  * Check if a process is running.
75
89
  */
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Options for {@link TerminalQueryResponder}.
3
+ */
4
+ export interface TerminalQueryResponderOptions {
5
+ /**
6
+ * Reply to `CSI 6 n` (report cursor position) with the home position. Set to
7
+ * `false` when the PTY host answers cursor reports itself: the only `CSI 6 n`
8
+ * left on the stream is then the host's own session-start handshake, whose
9
+ * reply would be delivered to the supervised program as unsolicited input
10
+ * instead of being consumed by the host. Defaults to `true`.
11
+ */
12
+ cursorPosition?: boolean;
13
+ }
1
14
  /**
2
15
  * Answers terminal capability queries emitted by programs on a headless PTY.
3
16
  *
@@ -16,6 +29,7 @@
16
29
  */
17
30
  export declare class TerminalQueryResponder {
18
31
  #private;
32
+ constructor(options?: TerminalQueryResponderOptions);
19
33
  /**
20
34
  * Feed one raw PTY output chunk. Returns the reply bytes to write back into
21
35
  * the PTY, or an empty string when the chunk held no answerable query.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.5.0",
3
+ "version": "18.5.1",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.5.0"
60
+ "@oh-my-pi/pi-natives": "18.5.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
@@ -820,8 +820,10 @@ function isBlockStart(lines: string[], index: number): boolean {
820
820
  return false;
821
821
  }
822
822
 
823
+ // `[^\n]`, not `.`: `.` stops at U+2028 and U+2029, which end no Markdown line,
824
+ // and the match then skips the text in front of them.
823
825
  function lineArray(src: string): string[] {
824
- return src.match(/.*(?:\n|$)/g)?.filter((line, index, all) => line !== "" || index < all.length - 1) ?? [];
826
+ return src.match(/[^\n]*(?:\n|$)/g)?.filter((line, index, all) => line !== "" || index < all.length - 1) ?? [];
825
827
  }
826
828
  function stripFinalNewline(value: string): string {
827
829
  return value.endsWith("\n") ? value.slice(0, -1) : value;
@@ -8,6 +8,9 @@
8
8
  /** Opening delimiter. Each closer (`$`, `$$`, `\)`, `\]`) is as wide as its opener. */
9
9
  export type MathOpener = "$" | "$$" | "\\(" | "\\[";
10
10
 
11
+ /** Opener of an own-line display block. */
12
+ export type MathBlockOpener = "$$" | "\\[";
13
+
11
14
  /** A closed math span found in the source. */
12
15
  export interface MathSpan {
13
16
  opener: MathOpener;
@@ -33,8 +36,8 @@ export interface MathBlock {
33
36
  // it. The own-line requirement leaves inline `$$…$$` inside prose to the span
34
37
  // grammar below. `\r?\n` at each line boundary keeps the grammar CRLF-safe for
35
38
  // direct callers; marked-fed renderers already normalize line endings first.
36
- const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\$\$[ \t]*(?:\r?\n|$)/;
37
- const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\\\][ \t]*(?:\r?\n|$)/;
39
+ const MATH_BLOCK_DOLLAR = / {0,3}\$\$[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\$\$[ \t]*(?:\r?\n|$)/y;
40
+ const MATH_BLOCK_BRACKET = / {0,3}\\\[[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\\\][ \t]*(?:\r?\n|$)/y;
38
41
 
39
42
  /**
40
43
  * Leftmost offset at or after `from` where an opener could begin. A scan hint,
@@ -182,9 +185,110 @@ function spanOf(source: string, opener: MathOpener, bodyStart: number, closeAt:
182
185
 
183
186
  /** The own-line display block starting at offset 0, or `undefined`. */
184
187
  export function mathBlockAt(source: string): MathBlock | undefined {
185
- const match = MATH_BLOCK_DOLLAR.exec(source) ?? MATH_BLOCK_BRACKET.exec(source);
186
- if (!match || match[1].trim() === "") return undefined;
187
- return { raw: match[0], body: match[1] };
188
+ MATH_BLOCK_DOLLAR.lastIndex = 0;
189
+ MATH_BLOCK_BRACKET.lastIndex = 0;
190
+ return blockOf(MATH_BLOCK_DOLLAR.exec(source) ?? MATH_BLOCK_BRACKET.exec(source));
191
+ }
192
+
193
+ /** The block a match of the block grammar describes; a whitespace-only body is no block. */
194
+ function blockOf(match: RegExpExecArray | null): MathBlock | undefined {
195
+ return match === null || match[1].trim() === "" ? undefined : { raw: match[0], body: match[1] };
196
+ }
197
+
198
+ // An own-line display opener line (`$$` or `\[` after up to 3 spaces), capturing
199
+ // the opener and the line's end: "" when it is the source's unterminated last line.
200
+ const MATH_BLOCK_OPENER_LINE_RE = / {0,3}(\$\$|\\\[)[ \t]*(\r?\n|$)/y;
201
+
202
+ /**
203
+ * {@link mathBlockAt} for many offsets of one `source`. An own-line opener
204
+ * whose closer line is missing means every later opener of the same kind
205
+ * misses one too, since its search covers a suffix of that search. The scan
206
+ * keeps that answer, so asking at every block start takes linear time instead
207
+ * of a scan to the end of `source` per unclosed opener.
208
+ */
209
+ export class MathBlockScan {
210
+ readonly #source: string;
211
+ // Offset of the first `$$` / `\[` opener line found with no closer line after it.
212
+ #unclosedDollarFrom = Number.POSITIVE_INFINITY;
213
+ #unclosedBracketFrom = Number.POSITIVE_INFINITY;
214
+
215
+ constructor(source: string) {
216
+ this.#source = source;
217
+ }
218
+
219
+ /** The own-line display block starting at `from`, or `undefined`. */
220
+ at(from: number): MathBlock | undefined {
221
+ MATH_BLOCK_OPENER_LINE_RE.lastIndex = from;
222
+ const line = MATH_BLOCK_OPENER_LINE_RE.exec(this.#source);
223
+ // An unterminated opener line opens no block.
224
+ if (line === null || line[2] === "") return undefined;
225
+ const dollar = line[1] === "$$";
226
+ if (from >= (dollar ? this.#unclosedDollarFrom : this.#unclosedBracketFrom)) return undefined;
227
+ const grammar = dollar ? MATH_BLOCK_DOLLAR : MATH_BLOCK_BRACKET;
228
+ grammar.lastIndex = from;
229
+ const match = grammar.exec(this.#source);
230
+ if (match === null) {
231
+ if (dollar) this.#unclosedDollarFrom = from;
232
+ else this.#unclosedBracketFrom = from;
233
+ }
234
+ return blockOf(match);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * The display opener alone on the line at `from` (up to 3 leading spaces,
240
+ * trailing spaces or tabs; the source's last line may be unterminated), or
241
+ * `undefined`.
242
+ */
243
+ export function mathBlockOpenerAt(source: string, from = 0): MathBlockOpener | undefined {
244
+ MATH_BLOCK_OPENER_LINE_RE.lastIndex = from;
245
+ const line = MATH_BLOCK_OPENER_LINE_RE.exec(source);
246
+ return line === null ? undefined : line[1] === "$$" ? "$$" : "\\[";
247
+ }
248
+
249
+ /**
250
+ * Whether the own-line display block opened at `from` in `source` is closed,
251
+ * or could still close once more text is appended: the text from `from`
252
+ * onward is a streaming prefix whose real closer may not have arrived. A
253
+ * closer on the unterminated last line has not arrived either, since the next
254
+ * append may extend it into text (`$$ E = mc^2 $$`). With no opener line at
255
+ * `from` it returns false before scanning, so a caller can ask at every block
256
+ * start.
257
+ */
258
+ export function mathBlockMayCloseAt(source: string, from = 0): boolean {
259
+ MATH_BLOCK_OPENER_LINE_RE.lastIndex = from;
260
+ const line = MATH_BLOCK_OPENER_LINE_RE.exec(source);
261
+ if (line === null) return false;
262
+ // The opener's own line is still being written.
263
+ if (line[2] === "") return true;
264
+ const grammar = line[1] === "$$" ? MATH_BLOCK_DOLLAR : MATH_BLOCK_BRACKET;
265
+ grammar.lastIndex = from;
266
+ const match = grammar.exec(source);
267
+ // No closer line yet, or only on the last line, which is still being written.
268
+ if (match === null || (from + match[0].length === source.length && !match[0].endsWith("\n"))) return true;
269
+ return blockOf(match) !== undefined;
270
+ }
271
+
272
+ // A display closer alone on its line, captured; the last line counts unterminated.
273
+ const MATH_BLOCK_CLOSER_LINE_RE = /(?<=^|\n) {0,3}(\$\$|\\\])[ \t]*(?=\r?\n|$)/g;
274
+
275
+ /**
276
+ * Offset of the first line of `source`, from the line start `from` on, that
277
+ * holds the closer of `opener` alone (up to 3 leading spaces, trailing spaces
278
+ * or tabs), the unterminated last line included, or `undefined`: the first
279
+ * line at which a block `opener` opened before `from` could close.
280
+ */
281
+ export function mathBlockCloserIndex(source: string, from: number, opener: MathBlockOpener): number | undefined {
282
+ const closer = opener === "$$" ? "$$" : "\\]";
283
+ MATH_BLOCK_CLOSER_LINE_RE.lastIndex = from;
284
+ for (
285
+ let line = MATH_BLOCK_CLOSER_LINE_RE.exec(source);
286
+ line !== null;
287
+ line = MATH_BLOCK_CLOSER_LINE_RE.exec(source)
288
+ ) {
289
+ if (line[1] === closer) return line.index;
290
+ }
291
+ return undefined;
188
292
  }
189
293
 
190
294
  /**
package/src/procmgr.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
4
4
  import type { Subprocess } from "bun";
5
- import { getAgentDir, MAIN_CONFIG_FILENAMES } from "./dirs";
5
+ import { getAgentDir, getProjectDir, MAIN_CONFIG_FILENAMES } from "./dirs";
6
6
  import { $env, filterChildShellEnv } from "./env";
7
7
  import { isExecutable } from "./executable";
8
8
  import { $which } from "./which";
@@ -20,18 +20,31 @@ export interface ShellConfigOptions {
20
20
  /** File path or runtime layer that supplied the active shell setting. */
21
21
  configSource?: string;
22
22
  }
23
- /** Auto-resolved shell (no custom path); stable for the process lifetime. */
23
+ /** Auto-resolved shell (no custom path); kept until {@link refreshShellConfigCache}. */
24
24
  let cachedDefaultShellConfig: ShellConfig | null = null;
25
25
  /** Config for the most recent custom `shellPath`; never served once the path is cleared. */
26
26
  let cachedCustomShellConfig: ShellConfig | null = null;
27
+ /**
28
+ * The child-shell environment {@link refreshShellConfigCache} last captured. Unset
29
+ * until the first refresh, so spawn environments are built from the live environment.
30
+ */
31
+ let capturedChildShellEnv: Record<string, string> | undefined;
32
+ /**
33
+ * Project whose dotenv values the spawn environment filters out: the one current
34
+ * when it was first built or captured.
35
+ * That is normally the launch project, whose dotenv files Bun and omp load into
36
+ * `process.env`; a later session in another project must not receive them.
37
+ */
38
+ let spawnEnvProjectDir: string | undefined;
27
39
 
28
40
  /**
29
41
  * Build the spawn environment (cached).
30
42
  */
31
43
  function buildSpawnEnv(shell: string): Record<string, string> {
32
44
  const noCI = $env.PI_BASH_NO_CI || $env.CLAUDE_BASH_NO_CI;
45
+ spawnEnvProjectDir ??= getProjectDir();
33
46
  return {
34
- ...filterChildShellEnv(Bun.env),
47
+ ...(capturedChildShellEnv ?? filterChildShellEnv(Bun.env, spawnEnvProjectDir)),
35
48
  SHELL: shell,
36
49
  GIT_EDITOR: "true",
37
50
  GPG_TTY: "not a tty",
@@ -202,6 +215,32 @@ export function getShellConfig(customShellPath?: string, options: ShellConfigOpt
202
215
  return cachedDefaultShellConfig;
203
216
  }
204
217
 
218
+ /**
219
+ * Capture the current process environment for child shells and drop both cached
220
+ * shell configs, so the next {@link getShellConfig} builds the spawn environment
221
+ * from that capture. Call right after code that changed `process.env` in a way
222
+ * child shells must see, such as extension session lifecycle handlers exporting
223
+ * session-scoped variables; later writes reach child shells only at the next call.
224
+ */
225
+ export function refreshShellConfigCache(): void {
226
+ spawnEnvProjectDir ??= getProjectDir();
227
+ capturedChildShellEnv = filterChildShellEnv(Bun.env, spawnEnvProjectDir);
228
+ cachedDefaultShellConfig = null;
229
+ cachedCustomShellConfig = null;
230
+ }
231
+
232
+ /**
233
+ * Test-only: forget the capture and both cached shell configs, so the next
234
+ * {@link getShellConfig} builds from the live environment again. Production code
235
+ * MUST NOT call this; the capture's lifecycle is owned by {@link refreshShellConfigCache}.
236
+ */
237
+ export function __resetShellConfigCacheForTests(): void {
238
+ capturedChildShellEnv = undefined;
239
+ spawnEnvProjectDir = undefined;
240
+ cachedDefaultShellConfig = null;
241
+ cachedCustomShellConfig = null;
242
+ }
243
+
205
244
  /** Platform shell discovery used when no custom `shellPath` is configured. */
206
245
  function resolveDefaultShell(): string {
207
246
  if (process.platform === "win32") {
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Options for {@link TerminalQueryResponder}.
3
+ */
4
+ export interface TerminalQueryResponderOptions {
5
+ /**
6
+ * Reply to `CSI 6 n` (report cursor position) with the home position. Set to
7
+ * `false` when the PTY host answers cursor reports itself: the only `CSI 6 n`
8
+ * left on the stream is then the host's own session-start handshake, whose
9
+ * reply would be delivered to the supervised program as unsolicited input
10
+ * instead of being consumed by the host. Defaults to `true`.
11
+ */
12
+ cursorPosition?: boolean;
13
+ }
14
+
1
15
  /**
2
16
  * Answers terminal capability queries emitted by programs on a headless PTY.
3
17
  *
@@ -17,6 +31,11 @@
17
31
  export class TerminalQueryResponder {
18
32
  /** Trailing bytes that may be the start of an unfinished query escape. */
19
33
  #residual = "";
34
+ readonly #cursorPosition: boolean;
35
+
36
+ constructor(options: TerminalQueryResponderOptions = {}) {
37
+ this.#cursorPosition = options.cursorPosition ?? true;
38
+ }
20
39
 
21
40
  /**
22
41
  * Feed one raw PTY output chunk. Returns the reply bytes to write back into
@@ -29,7 +48,7 @@ export class TerminalQueryResponder {
29
48
  QUERY.lastIndex = 0;
30
49
  for (let match = QUERY.exec(buffer); match !== null; match = QUERY.exec(buffer)) {
31
50
  lastEnd = match.index + match[0].length;
32
- replies += replyFor(match);
51
+ replies += replyFor(match, this.#cursorPosition);
33
52
  }
34
53
  // Keep only a short unmatched trailing escape: a query split across
35
54
  // chunks completes on the next feed, while a long tail is ordinary output
@@ -51,7 +70,7 @@ const MAX_PARTIAL_QUERY = 32;
51
70
  const QUERY = /\x1b\[([?>=]?)([0-9;]*)([nc])|\x1b\](10|11);\?(\x07|\x1b\\)/gu;
52
71
 
53
72
  /** Reply a real xterm-class terminal would send for one matched query. */
54
- function replyFor(match: RegExpExecArray): string {
73
+ function replyFor(match: RegExpExecArray, cursorPosition: boolean): string {
55
74
  const final = match[3];
56
75
  if (final !== undefined) {
57
76
  const intermediate = match[1];
@@ -63,7 +82,7 @@ function replyFor(match: RegExpExecArray): string {
63
82
  }
64
83
  if (intermediate !== "") return ""; // private DSR forms (DECXCPR, appearance) stay unanswered
65
84
  const selector = params.split(";", 1)[0];
66
- if (selector === "6") return "\x1b[1;1R"; // cursor position: home, there is no screen
85
+ if (selector === "6") return cursorPosition ? "\x1b[1;1R" : ""; // cursor position: home, there is no screen
67
86
  if (selector === "5") return "\x1b[0n"; // device status: OK
68
87
  return "";
69
88
  }