@firstpick/pi-utils 0.1.8 → 0.2.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
@@ -26,8 +26,17 @@ Shared helper utilities used by `@firstpick/pi-extension-*` packages.
26
26
  - `buildInitialPromptCalibrationRecord(args)`
27
27
  - `appendInitialPromptCalibrationRecord(appendEntry, record)`
28
28
  - `delay(ms)`
29
+ - `tokenizeArgs(input)` / `takeValue(tokens, index, flag)`
30
+ - `readJsonFile(path)` / `readJsonIfExists(path, fallback)` / `writeJsonFile(path, data)`
31
+ - `runCommand(command, args, options?)` / `runShellCommand(cwd, command, options?)`
32
+ - `shellQuote(value)` / `stripAnsi(input)` / `resolveExecutableFromPath(name)`
33
+ - `jsonToolResult(payload)` / `textToolResult(text, details?)`
34
+ - `createRunLog(cwd)` / `appendRunLog(log, chunk)` / `saveRunLog(log, options)` / `listRunLogs(dir)`
35
+ - `parseChecklistLine(line)` / `extractChecklist(text)` / `stripChecklistLines(text)` / `countChecklistProgress(textOrItems)`
36
+ - `expandTilde(input)` / `resolveUserPath(input, cwd?)` / `safeResolveInside(base, ref)` / `formatUserPath(path)`
29
37
  - `createExtensionWorkingIndicator(ctx, initialMessage, options?)`
30
38
  - `withExtensionWorkingIndicator(ctx, initialMessage, run, options?)`
39
+ - `appendDisplayChunk(lines, chunk)` / `outputLinesFromDisplay(lines)` / `formatElapsed(startMs)`
31
40
  - `createLocalWikiEngine(config)`
32
41
 
33
42
  `createExtensionWorkingIndicator` renders a reusable extension-owned spinner using `ctx.ui.setWidget` plus footer `setStatus`, so it works inside slash-command handlers where Pi's built-in model-streaming working row is not shown.
package/index.ts CHANGED
@@ -4,8 +4,17 @@ export * from "./src/text";
4
4
  export * from "./src/tokens";
5
5
  export * from "./src/prompt-calibration";
6
6
  export * from "./src/prompt-export-estimate";
7
+ export * from "./src/initial-prompt-estimate-state";
8
+ export * from "./src/initial-prompt-estimate-service";
7
9
  export * from "./src/async";
10
+ export * from "./src/cli";
11
+ export * from "./src/json";
12
+ export * from "./src/process";
13
+ export * from "./src/tool-result";
14
+ export * from "./src/markdown";
15
+ export * from "./src/release-log";
8
16
  export * from "./src/ui/working-indicator";
17
+ export * from "./src/ui/live-output";
9
18
  export * from "./src/local-wiki";
10
19
 
11
20
  export { default } from "./src/extension";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-utils",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Shared utilities for Firstpick Pi extension packages.",
5
5
  "main": "index.ts",
6
6
  "exports": {
@@ -11,11 +11,25 @@
11
11
  "./tokens": "./src/tokens.ts",
12
12
  "./prompt-calibration": "./src/prompt-calibration.ts",
13
13
  "./prompt-export-estimate": "./src/prompt-export-estimate.ts",
14
+ "./initial-prompt-estimate-state": "./src/initial-prompt-estimate-state.ts",
15
+ "./initial-prompt-estimate-service": "./src/initial-prompt-estimate-service.ts",
14
16
  "./async": "./src/async.ts",
17
+ "./cli": "./src/cli.ts",
18
+ "./json": "./src/json.ts",
19
+ "./process": "./src/process.ts",
20
+ "./tool-result": "./src/tool-result.ts",
21
+ "./markdown": "./src/markdown.ts",
22
+ "./release-log": "./src/release-log.ts",
15
23
  "./ui": "./src/ui/working-indicator.ts",
24
+ "./ui/live-output": "./src/ui/live-output.ts",
16
25
  "./local-wiki": "./src/local-wiki.ts"
17
26
  },
18
27
  "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/Firstp1ck/npm-packages.git",
31
+ "directory": "pi-utils"
32
+ },
19
33
  "keywords": [
20
34
  "pi-package",
21
35
  "pi",
@@ -27,6 +41,10 @@
27
41
  "./index.ts"
28
42
  ]
29
43
  },
44
+ "scripts": {
45
+ "check": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types tests/initial-prompt-estimate-state.test.mjs",
46
+ "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types tests/initial-prompt-estimate-state.test.mjs"
47
+ },
30
48
  "peerDependencies": {
31
49
  "@earendil-works/pi-coding-agent": "*"
32
50
  },
package/src/cli.ts ADDED
@@ -0,0 +1,56 @@
1
+ export function tokenizeArgs(input: string): string[] {
2
+ const tokens: string[] = [];
3
+ let current = "";
4
+ let quote: '"' | "'" | undefined;
5
+ let escaped = false;
6
+
7
+ for (const char of input) {
8
+ if (escaped) {
9
+ current += char;
10
+ escaped = false;
11
+ continue;
12
+ }
13
+
14
+ if (char === "\\") {
15
+ escaped = true;
16
+ continue;
17
+ }
18
+
19
+ if (quote) {
20
+ if (char === quote) quote = undefined;
21
+ else current += char;
22
+ continue;
23
+ }
24
+
25
+ if (char === '"' || char === "'") {
26
+ quote = char;
27
+ continue;
28
+ }
29
+
30
+ if (/\s/.test(char)) {
31
+ if (current) {
32
+ tokens.push(current);
33
+ current = "";
34
+ }
35
+ continue;
36
+ }
37
+
38
+ current += char;
39
+ }
40
+
41
+ if (escaped) current += "\\";
42
+ if (quote) throw new Error(`Unclosed ${quote} quote`);
43
+ if (current) tokens.push(current);
44
+ return tokens;
45
+ }
46
+
47
+ export function takeValue(tokens: string[], index: number, flag: string): string {
48
+ const value = tokens[index + 1];
49
+ if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
50
+ return value;
51
+ }
52
+
53
+ export function takeOptionalValue(tokens: string[], index: number): string | undefined {
54
+ const value = tokens[index + 1];
55
+ return value && !value.startsWith("--") ? value : undefined;
56
+ }
@@ -0,0 +1,202 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { InitialPromptCalibration, InitialPromptInputEstimate, InitialPromptToolInfo } from "./tokens";
3
+ import {
4
+ estimateInitialPromptForPiContext,
5
+ estimateInitialPromptFromPiExport,
6
+ getActiveInitialPromptToolInfos,
7
+ type ExportBackedInitialPromptEstimate,
8
+ } from "./prompt-export-estimate";
9
+ import {
10
+ buildInitialPromptEstimateKey,
11
+ resolveInitialPromptEstimateRefreshDecision,
12
+ } from "./initial-prompt-estimate-state";
13
+
14
+ export type InitialPromptEstimatePiApi = Pick<ExtensionAPI, "getActiveTools" | "getAllTools">;
15
+ export type InitialPromptEstimateContext = Pick<ExtensionContext, "getSystemPrompt" | "sessionManager">;
16
+ export type InitialPromptEstimateSource = ExportBackedInitialPromptEstimate["source"] | "fallback";
17
+
18
+ export type InitialPromptEstimateSnapshot = {
19
+ key: string;
20
+ estimate: InitialPromptInputEstimate;
21
+ systemPrompt: string;
22
+ tools: InitialPromptToolInfo[];
23
+ source: InitialPromptEstimateSource;
24
+ settled: boolean;
25
+ attempts: number;
26
+ warning?: string;
27
+ };
28
+
29
+ export type InitialPromptCalibrationGetter<Ctx extends InitialPromptEstimateContext> = (
30
+ ctx: Ctx,
31
+ ) => InitialPromptCalibration | null | undefined;
32
+
33
+ export type StableInitialPromptEstimateOptions = {
34
+ maxAttempts?: number;
35
+ };
36
+
37
+ export type InitialPromptEstimateServiceOptions<Ctx extends InitialPromptEstimateContext> = StableInitialPromptEstimateOptions & {
38
+ pi: InitialPromptEstimatePiApi;
39
+ getCalibration: InitialPromptCalibrationGetter<Ctx>;
40
+ /** Publish provisional fallback snapshots while export-backed estimation is still running. */
41
+ publishFallback?: boolean;
42
+ onUpdate?: (snapshot: InitialPromptEstimateSnapshot, ctx: Ctx) => void;
43
+ };
44
+
45
+ export type InitialPromptEstimateRefreshResult =
46
+ | { status: "updated"; snapshot: InitialPromptEstimateSnapshot }
47
+ | { status: "unsettled"; snapshot: InitialPromptEstimateSnapshot }
48
+ | { status: "stale"; snapshot: null };
49
+
50
+ const DEFAULT_STABLE_ESTIMATE_ATTEMPTS = 3;
51
+
52
+ function resolveMaxAttempts(value: number | undefined): number {
53
+ const attempts = Math.floor(Number(value ?? DEFAULT_STABLE_ESTIMATE_ATTEMPTS));
54
+ return Number.isFinite(attempts) && attempts > 0 ? attempts : DEFAULT_STABLE_ESTIMATE_ATTEMPTS;
55
+ }
56
+
57
+ function appendWarning(existing: string | undefined, warning: string): string {
58
+ return existing ? `${existing} ${warning}` : warning;
59
+ }
60
+
61
+ export function buildInitialPromptFallbackSnapshot(
62
+ pi: InitialPromptEstimatePiApi,
63
+ ctx: InitialPromptEstimateContext,
64
+ calibration?: InitialPromptCalibration | null,
65
+ attempts = 0,
66
+ ): InitialPromptEstimateSnapshot {
67
+ const systemPrompt = ctx.getSystemPrompt();
68
+ const tools = getActiveInitialPromptToolInfos(pi);
69
+ const estimate = estimateInitialPromptForPiContext(pi, systemPrompt, calibration, tools);
70
+ return {
71
+ key: buildInitialPromptEstimateKey(estimate),
72
+ estimate,
73
+ systemPrompt,
74
+ tools,
75
+ source: "fallback",
76
+ settled: false,
77
+ attempts,
78
+ };
79
+ }
80
+
81
+ function snapshotFromExportEstimate(
82
+ key: string,
83
+ promptEstimate: ExportBackedInitialPromptEstimate,
84
+ attempts: number,
85
+ ): InitialPromptEstimateSnapshot {
86
+ return {
87
+ key,
88
+ estimate: promptEstimate.estimate,
89
+ systemPrompt: promptEstimate.systemPrompt,
90
+ tools: promptEstimate.tools,
91
+ source: promptEstimate.source,
92
+ settled: true,
93
+ attempts,
94
+ warning: promptEstimate.warning,
95
+ };
96
+ }
97
+
98
+ export async function estimateStableInitialPromptFromPiContext<Ctx extends InitialPromptEstimateContext>(
99
+ pi: InitialPromptEstimatePiApi,
100
+ ctx: Ctx,
101
+ getCalibration: InitialPromptCalibrationGetter<Ctx>,
102
+ options: StableInitialPromptEstimateOptions = {},
103
+ ): Promise<InitialPromptEstimateSnapshot> {
104
+ const maxAttempts = resolveMaxAttempts(options.maxAttempts);
105
+ let latestFallback = buildInitialPromptFallbackSnapshot(pi, ctx, getCalibration(ctx) ?? null, 0);
106
+
107
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
108
+ const calibration = getCalibration(ctx) ?? null;
109
+ const fallback = buildInitialPromptFallbackSnapshot(pi, ctx, calibration, attempt - 1);
110
+ const promptEstimate = await estimateInitialPromptFromPiExport(pi, ctx, calibration);
111
+ latestFallback = buildInitialPromptFallbackSnapshot(pi, ctx, getCalibration(ctx) ?? null, attempt);
112
+
113
+ const decision = resolveInitialPromptEstimateRefreshDecision({
114
+ requestId: attempt,
115
+ currentRequestId: attempt,
116
+ initialKey: fallback.key,
117
+ latestKey: latestFallback.key,
118
+ });
119
+ if (decision === "accept-exported-estimate") {
120
+ return snapshotFromExportEstimate(fallback.key, promptEstimate, attempt);
121
+ }
122
+ }
123
+
124
+ return {
125
+ ...latestFallback,
126
+ attempts: maxAttempts,
127
+ warning: appendWarning(
128
+ latestFallback.warning,
129
+ "Initial prompt inputs changed while estimating; used live context fallback.",
130
+ ),
131
+ };
132
+ }
133
+
134
+ export function createInitialPromptEstimateService<Ctx extends InitialPromptEstimateContext>(
135
+ options: InitialPromptEstimateServiceOptions<Ctx>,
136
+ ) {
137
+ let snapshot: InitialPromptEstimateSnapshot | null = null;
138
+ let activeRequestId = 0;
139
+
140
+ const publish = (nextSnapshot: InitialPromptEstimateSnapshot, ctx: Ctx): InitialPromptEstimateSnapshot => {
141
+ snapshot = nextSnapshot;
142
+ options.onUpdate?.(nextSnapshot, ctx);
143
+ return nextSnapshot;
144
+ };
145
+
146
+ const getFallbackSnapshot = (ctx: Ctx, attempts = 0): InitialPromptEstimateSnapshot => {
147
+ return buildInitialPromptFallbackSnapshot(options.pi, ctx, options.getCalibration(ctx) ?? null, attempts);
148
+ };
149
+
150
+ const refresh = async (ctx: Ctx): Promise<InitialPromptEstimateRefreshResult> => {
151
+ const requestId = ++activeRequestId;
152
+ const maxAttempts = resolveMaxAttempts(options.maxAttempts);
153
+ let latestFallback = getFallbackSnapshot(ctx, 0);
154
+
155
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
156
+ const calibration = options.getCalibration(ctx) ?? null;
157
+ const fallback = buildInitialPromptFallbackSnapshot(options.pi, ctx, calibration, attempt - 1);
158
+ latestFallback = fallback;
159
+ if (options.publishFallback) publish(fallback, ctx);
160
+
161
+ const promptEstimate = await estimateInitialPromptFromPiExport(options.pi, ctx, calibration);
162
+ latestFallback = getFallbackSnapshot(ctx, attempt);
163
+
164
+ const decision = resolveInitialPromptEstimateRefreshDecision({
165
+ requestId,
166
+ currentRequestId: activeRequestId,
167
+ initialKey: fallback.key,
168
+ latestKey: latestFallback.key,
169
+ });
170
+ if (decision === "ignore-stale-request") return { status: "stale", snapshot: null };
171
+ if (decision === "restart-inputs-changed") continue;
172
+
173
+ return {
174
+ status: "updated",
175
+ snapshot: publish(snapshotFromExportEstimate(fallback.key, promptEstimate, attempt), ctx),
176
+ };
177
+ }
178
+
179
+ const unsettled = {
180
+ ...latestFallback,
181
+ attempts: maxAttempts,
182
+ warning: appendWarning(
183
+ latestFallback.warning,
184
+ "Initial prompt inputs changed while estimating; kept the previous settled estimate.",
185
+ ),
186
+ };
187
+ if (options.publishFallback) publish(unsettled, ctx);
188
+ return { status: "unsettled", snapshot: unsettled };
189
+ };
190
+
191
+ return {
192
+ clear() {
193
+ activeRequestId++;
194
+ snapshot = null;
195
+ },
196
+ getSnapshot() {
197
+ return snapshot;
198
+ },
199
+ getFallbackSnapshot,
200
+ refresh,
201
+ };
202
+ }
@@ -0,0 +1,48 @@
1
+ export type InitialPromptEstimateRefreshDecision =
2
+ | "accept-exported-estimate"
3
+ | "ignore-stale-request"
4
+ | "restart-inputs-changed";
5
+
6
+ export type InitialPromptEstimateRefreshDecisionInput = {
7
+ requestId: number;
8
+ currentRequestId: number;
9
+ initialKey: string;
10
+ latestKey: string;
11
+ };
12
+
13
+ export type InitialPromptEstimateKeyInput = {
14
+ uncalibratedTotal: number;
15
+ promptText: number;
16
+ toolSchemas: number;
17
+ framing: number;
18
+ toolCount: number;
19
+ calibrationMultiplier: number;
20
+ calibrationSamples: number;
21
+ low: number;
22
+ high: number;
23
+ };
24
+
25
+ export function buildInitialPromptEstimateKey(estimate: InitialPromptEstimateKeyInput): string {
26
+ return [
27
+ estimate.uncalibratedTotal,
28
+ estimate.promptText,
29
+ estimate.toolSchemas,
30
+ estimate.framing,
31
+ estimate.toolCount,
32
+ estimate.calibrationMultiplier,
33
+ estimate.calibrationSamples,
34
+ estimate.low,
35
+ estimate.high,
36
+ ].join(":");
37
+ }
38
+
39
+ export function resolveInitialPromptEstimateRefreshDecision({
40
+ requestId,
41
+ currentRequestId,
42
+ initialKey,
43
+ latestKey,
44
+ }: InitialPromptEstimateRefreshDecisionInput): InitialPromptEstimateRefreshDecision {
45
+ if (requestId !== currentRequestId) return "ignore-stale-request";
46
+ if (latestKey !== initialKey) return "restart-inputs-changed";
47
+ return "accept-exported-estimate";
48
+ }
package/src/json.ts ADDED
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function readJsonFile<T = unknown>(filePath: string): T {
5
+ return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
6
+ }
7
+
8
+ export function readJsonIfExists<T>(filePath: string, fallback: T): T {
9
+ if (!fs.existsSync(filePath)) return fallback;
10
+ return readJsonFile<T>(filePath);
11
+ }
12
+
13
+ export function writeJsonFile(filePath: string, data: unknown, options: { mode?: number } = {}): void {
14
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
15
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: options.mode });
16
+ }
17
+
18
+ export async function readJsonFileAsync<T = unknown>(filePath: string): Promise<T> {
19
+ return JSON.parse(await fs.promises.readFile(filePath, "utf8")) as T;
20
+ }
21
+
22
+ export async function readJsonIfExistsAsync<T>(filePath: string, fallback: T): Promise<T> {
23
+ try {
24
+ return await readJsonFileAsync<T>(filePath);
25
+ } catch (error) {
26
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback;
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ export async function writeJsonFileAsync(filePath: string, data: unknown, options: { mode?: number } = {}): Promise<void> {
32
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
33
+ await fs.promises.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: options.mode });
34
+ }
package/src/local-wiki.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import fsp from "node:fs/promises";
3
3
 
4
- export type LocalWikiFormat = "markdown" | "html";
4
+ export type LocalWikiFormat = "markdown" | "html" | "asciidoc";
5
5
 
6
6
  export interface LocalWikiSection {
7
7
  title: string;
@@ -68,6 +68,9 @@ export interface LocalWikiEngineConfig {
68
68
  statusExtra?: () => Promise<Record<string, unknown>>;
69
69
  transformText?: (text: string, title: string, filePath: string) => string;
70
70
  titleFromHtml?: (html: string, filePath: string, fallback: string) => string;
71
+ /** Expand AsciiDoc include:: directives before parsing text/sections. Defaults to true for asciidoc format. */
72
+ expandIncludes?: boolean;
73
+ maxIncludeDepth?: number;
71
74
  }
72
75
 
73
76
  export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
@@ -130,6 +133,31 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
130
133
  .trim();
131
134
  }
132
135
 
136
+ function stripAsciidocDecorators(input: string): string {
137
+ return input
138
+ .replace(/^={1,6}\s+/, "")
139
+ .replace(/^\[\[[^\]]+\]\]\s*/, "")
140
+ .replace(/xref:([^\[]+)\[([^\]]*)\]/g, (_m, target: string, label: string) => label || titleFromPath(target))
141
+ .replace(/https?:[^\[]+\[([^\]]+)\]/g, "$1")
142
+ .replace(/(?:kbd|btn|menu):\[([^\]]+)\]/g, "$1")
143
+ .replace(/[*_`]/g, "")
144
+ .trim();
145
+ }
146
+
147
+ function asciidocToText(asciidoc: string): string {
148
+ return normalizeWhitespace(asciidoc
149
+ .replace(/^\s*:[^:\n]+:.*$/gm, "")
150
+ .replace(/^\s*\/\/.*$/gm, "")
151
+ .replace(/^\[\[[^\]]+\]\]\s*$/gm, "")
152
+ .replace(/^\[(?:source|console|bash|python|json|ini|subs|NOTE|TIP|IMPORTANT|WARNING|CAUTION)[^\]]*\]\s*$/gim, "")
153
+ .replace(/^(?:----|====|\+{4}|`{3})\s*$/gm, "")
154
+ .replace(/^image::[^\[]+\[[^\]]*\]\s*$/gm, "")
155
+ .replace(/include::([^\[]+)\[[^\]]*\]/g, "")
156
+ .replace(/xref:([^\[]+)\[([^\]]*)\]/g, (_m, target: string, label: string) => label || titleFromPath(target))
157
+ .replace(/https?:[^\[]+\[([^\]]+)\]/g, "$1")
158
+ .replace(/(?:kbd|btn|menu):\[([^\]]+)\]/g, "$1"));
159
+ }
160
+
133
161
  function stripYamlFrontmatter(markdown: string): string {
134
162
  return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
135
163
  }
@@ -195,6 +223,33 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
195
223
  return sections;
196
224
  }
197
225
 
226
+ function asciidocTitle(asciidoc: string, filePath: string): string {
227
+ for (const line of asciidoc.split(/\n/)) {
228
+ const match = line.match(/^(={1,6})\s+(.+)$/);
229
+ if (match) return stripAsciidocDecorators(match[2]);
230
+ }
231
+ return titleFromPath(filePath);
232
+ }
233
+
234
+ function asciidocSections(asciidoc: string, fallbackTitle: string): LocalWikiSection[] {
235
+ const sections: LocalWikiSection[] = [];
236
+ let current: LocalWikiSection | undefined;
237
+ for (const line of asciidoc.split(/\n/)) {
238
+ const match = line.match(/^(={1,6})\s+(.+)$/);
239
+ if (match) {
240
+ if (current) current.text = asciidocToText(current.text);
241
+ const title = stripAsciidocDecorators(match[2]);
242
+ current = { title, level: match[1].length, anchor: anchorFromHeading(title), text: "" };
243
+ sections.push(current);
244
+ continue;
245
+ }
246
+ if (current) current.text += `${line}\n`;
247
+ }
248
+ if (!current) sections.push({ title: fallbackTitle, level: 1, anchor: anchorFromHeading(fallbackTitle), text: asciidocToText(asciidoc) });
249
+ else current.text = asciidocToText(current.text);
250
+ return sections;
251
+ }
252
+
198
253
  function htmlToText(html: string): string {
199
254
  let body = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] ?? html;
200
255
  body = body.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ");
@@ -215,15 +270,35 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
215
270
  return (config.titleFromHtml?.(html, filePath, fallback) ?? stripTags(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? "")) || fallback;
216
271
  }
217
272
 
273
+ function candidateLocalPaths(currentFile: string, href: string): string[] {
274
+ if (/^(https?:|mailto:|#)/i.test(href)) return [];
275
+ const cleanHref = decodeEntities(href).split("#")[0].split("?")[0].trim();
276
+ if (!cleanHref) return [];
277
+ const variants = [...new Set([cleanHref, cleanHref.replace(/^\.\/+/g, "")].filter(Boolean))];
278
+ const expand = (candidate: string): string[] => {
279
+ if (path.extname(candidate)) return [candidate];
280
+ if (config.format === "html") return [`${candidate}.html`, `${candidate}.htm`, path.join(candidate, "index.html")];
281
+ if (config.format === "asciidoc") return [`${candidate}.adoc`, `${candidate}.asciidoc`, `${candidate}.asc`, path.join(candidate, "index.adoc")];
282
+ return [`${candidate}.md`, `${candidate}.mdx`, `${candidate}.rst`, path.join(candidate, "index.md")];
283
+ };
284
+ const bases = [path.dirname(currentFile), path.dirname(path.dirname(currentFile)), config.docsPath];
285
+ const resolved: string[] = [];
286
+ for (const variant of variants) {
287
+ for (const expanded of expand(variant)) {
288
+ if (path.isAbsolute(expanded)) resolved.push(path.normalize(expanded));
289
+ for (const base of bases) resolved.push(path.normalize(path.resolve(base, expanded)));
290
+ }
291
+ }
292
+ return [...new Set(resolved)].filter((candidate) => candidate === config.docsPath || candidate.startsWith(`${config.docsPath}${path.sep}`));
293
+ }
294
+
218
295
  function resolveLocalPath(currentFile: string, href: string): string | undefined {
219
- if (/^(https?:|mailto:|#)/i.test(href)) return undefined;
220
- const cleanHref = decodeEntities(href).split("#")[0].split("?")[0];
221
- if (!cleanHref) return undefined;
222
- const ext = config.format === "html" ? ".html" : ".md";
223
- const candidates = path.extname(cleanHref) ? [cleanHref] : [cleanHref + ext, `${cleanHref}.mdx`, `${cleanHref}.rst`, path.join(cleanHref, "index.md")];
224
- for (const candidate of candidates) {
225
- const resolved = path.normalize(path.resolve(path.dirname(currentFile), candidate));
226
- if (resolved.startsWith(config.docsPath)) return resolved;
296
+ return candidateLocalPaths(currentFile, href)[0];
297
+ }
298
+
299
+ async function resolveExistingLocalPath(currentFile: string, href: string): Promise<string | undefined> {
300
+ for (const candidate of candidateLocalPaths(currentFile, href)) {
301
+ if (await localExists(candidate)) return candidate;
227
302
  }
228
303
  return undefined;
229
304
  }
@@ -238,6 +313,38 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
238
313
  return [...links.values()];
239
314
  }
240
315
 
316
+ function asciidocLinks(asciidoc: string, currentFile: string): LocalWikiLink[] {
317
+ const links = new Map<string, LocalWikiLink>();
318
+ const add = (href: string, label: string) => {
319
+ const resolved = resolveLocalPath(currentFile, href.trim());
320
+ if (!resolved) return;
321
+ links.set(resolved, { title: stripAsciidocDecorators(label) || titleFromPath(resolved), path: resolved });
322
+ };
323
+ for (const match of asciidoc.matchAll(/xref:([^\[]+)\[([^\]]*)\]/g)) add(match[1], match[2]);
324
+ for (const match of asciidoc.matchAll(/^include::([^\[]+)\[([^\]]*)\]/gm)) add(match[1], match[2]);
325
+ return [...links.values()];
326
+ }
327
+
328
+ async function expandAsciidocIncludes(raw: string, currentFile: string, depth = 0, seen = new Set<string>()): Promise<string> {
329
+ const maxDepth = Math.max(0, config.maxIncludeDepth ?? 4);
330
+ if (depth >= maxDepth) return raw;
331
+ const includeRe = /^include::([^\[]+)\[[^\]]*\]\s*$/gm;
332
+ const replacements = await Promise.all([...raw.matchAll(includeRe)].map(async (match) => {
333
+ const resolved = await resolveExistingLocalPath(currentFile, match[1].trim());
334
+ if (!resolved || seen.has(resolved)) return { from: match[0], to: "" };
335
+ try {
336
+ seen.add(resolved);
337
+ const included = await fsp.readFile(resolved, "utf8");
338
+ return { from: match[0], to: await expandAsciidocIncludes(included, resolved, depth + 1, seen) };
339
+ } catch {
340
+ return { from: match[0], to: "" };
341
+ }
342
+ }));
343
+ let expanded = raw;
344
+ for (const { from, to } of replacements) expanded = expanded.replace(from, to);
345
+ return expanded;
346
+ }
347
+
241
348
  function htmlLinks(html: string, currentFile: string): LocalWikiLink[] {
242
349
  const links = new Map<string, LocalWikiLink>();
243
350
  for (const match of html.matchAll(/<a\s+[^>]*href=["']([^"'#?]+)(?:#[^"']*)?["'][^>]*>([\s\S]*?)<\/a>/gi)) {
@@ -248,13 +355,29 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
248
355
  return [...links.values()];
249
356
  }
250
357
 
251
- function parsePage(raw: string, filePath: string, mtimeMs: number): LocalWikiPage {
252
- const title = config.format === "html" ? htmlTitle(raw, filePath) : markdownTitle(raw, filePath);
253
- const markdownBody = config.format === "html" ? raw : stripYamlFrontmatter(raw);
254
- const baseText = config.format === "html" ? htmlToText(raw) : normalizeWhitespace(markdownBody);
358
+ function parsePage(raw: string, filePath: string, mtimeMs: number, sourceRaw = raw): LocalWikiPage {
359
+ if (config.format === "html") {
360
+ const title = htmlTitle(sourceRaw, filePath);
361
+ const baseText = htmlToText(raw);
362
+ const text = config.transformText?.(baseText, title, filePath) ?? baseText;
363
+ const sections = markdownSections(text, title);
364
+ return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: htmlLinks(sourceRaw, filePath), text, mtimeMs };
365
+ }
366
+
367
+ if (config.format === "asciidoc") {
368
+ const title = asciidocTitle(sourceRaw, filePath);
369
+ const baseText = asciidocToText(raw);
370
+ const text = config.transformText?.(baseText, title, filePath) ?? baseText;
371
+ const sections = asciidocSections(raw, title);
372
+ return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: asciidocLinks(sourceRaw, filePath), text, mtimeMs };
373
+ }
374
+
375
+ const title = markdownTitle(sourceRaw, filePath);
376
+ const markdownBody = stripYamlFrontmatter(raw);
377
+ const baseText = normalizeWhitespace(markdownBody);
255
378
  const text = config.transformText?.(baseText, title, filePath) ?? baseText;
256
379
  const sections = markdownSections(text, title);
257
- return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: config.format === "html" ? htmlLinks(raw, filePath) : markdownLinks(markdownBody, filePath), text, mtimeMs };
380
+ return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: markdownLinks(markdownBody, filePath), text, mtimeMs };
258
381
  }
259
382
 
260
383
  function limitText(text: string, maxChars = 12000): { text: string; truncated: boolean } {
@@ -270,7 +393,10 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
270
393
  for (const file of files) {
271
394
  const stat = await fsp.stat(file);
272
395
  newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs);
273
- pages.push(parsePage(await fsp.readFile(file, "utf8"), file, stat.mtimeMs));
396
+ const raw = await fsp.readFile(file, "utf8");
397
+ const shouldExpandIncludes = config.format === "asciidoc" && config.expandIncludes !== false;
398
+ const expanded = shouldExpandIncludes ? await expandAsciidocIncludes(raw, file) : raw;
399
+ pages.push(parsePage(expanded, file, stat.mtimeMs, raw));
274
400
  }
275
401
  const metadata: LocalWikiCacheMetadata = { schemaVersion, docsPath: config.docsPath, generatedAt: new Date().toISOString(), pageCount: pages.length, newestMtimeMs, extra: await config.metadataExtra?.() };
276
402
  await fsp.writeFile(pagesCache, JSON.stringify(pages, null, 2));
@@ -0,0 +1,77 @@
1
+ export type ChecklistStatus = "todo" | "partial" | "done";
2
+
3
+ export type ChecklistItem = {
4
+ text: string;
5
+ status: ChecklistStatus;
6
+ };
7
+
8
+ export type ChecklistProgress = {
9
+ total: number;
10
+ done: number;
11
+ partial: number;
12
+ remaining: number;
13
+ };
14
+
15
+ export const CHECKLIST_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[( |x|X|-)\]\s+(.+)$/;
16
+
17
+ export function parseChecklistLine(line: string): ChecklistItem | undefined {
18
+ const match = CHECKLIST_LINE_REGEX.exec(line);
19
+ if (!match) return undefined;
20
+
21
+ const mark = (match[1] || " ").toLowerCase();
22
+ const label = (match[2] || "").trim().replace(/\s+/g, " ");
23
+ if (!label) return undefined;
24
+
25
+ return {
26
+ status: mark === "x" ? "done" : mark === "-" ? "partial" : "todo",
27
+ text: label,
28
+ };
29
+ }
30
+
31
+ export function extractChecklist(text: string): ChecklistItem[] {
32
+ const checklist: ChecklistItem[] = [];
33
+ let inFence = false;
34
+
35
+ for (const line of text.split(/\r?\n/)) {
36
+ if (/^\s*```/.test(line)) {
37
+ inFence = !inFence;
38
+ continue;
39
+ }
40
+ if (inFence) continue;
41
+
42
+ const item = parseChecklistLine(line);
43
+ if (item) checklist.push(item);
44
+ }
45
+
46
+ return checklist;
47
+ }
48
+
49
+ export function stripChecklistLines(text: string): string {
50
+ let inFence = false;
51
+ const kept: string[] = [];
52
+
53
+ for (const line of text.split(/\r?\n/)) {
54
+ if (/^\s*```/.test(line)) {
55
+ inFence = !inFence;
56
+ kept.push(line);
57
+ continue;
58
+ }
59
+
60
+ if (!inFence && parseChecklistLine(line)) continue;
61
+ kept.push(line);
62
+ }
63
+
64
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
65
+ }
66
+
67
+ export function countChecklistProgress(textOrItems: string | ChecklistItem[]): ChecklistProgress {
68
+ const items = typeof textOrItems === "string" ? extractChecklist(textOrItems) : textOrItems;
69
+ const done = items.filter((item) => item.status === "done").length;
70
+ const partial = items.filter((item) => item.status === "partial").length;
71
+ return {
72
+ total: items.length,
73
+ done,
74
+ partial,
75
+ remaining: Math.max(0, items.length - done),
76
+ };
77
+ }
package/src/paths.ts CHANGED
@@ -26,3 +26,41 @@ export function getAgentSettingsPath(): string {
26
26
  export function getWorkspaceEnvPath(cwd = process.cwd()): string {
27
27
  return path.join(cwd, ".env");
28
28
  }
29
+
30
+ export function expandTilde(input: string, homeDir = os.homedir()): string {
31
+ if (input === "~") return homeDir;
32
+ if (input.startsWith("~/")) return path.join(homeDir, input.slice(2));
33
+ if (input === "$HOME") return homeDir;
34
+ if (input.startsWith("$HOME/")) return path.join(homeDir, input.slice(6));
35
+ return input;
36
+ }
37
+
38
+ export function stripAtPathPrefix(input: string): string {
39
+ return input.trim().replace(/^@+/, "");
40
+ }
41
+
42
+ export function resolveUserPath(input: string, cwd = process.cwd(), options: { stripAtPrefix?: boolean } = {}): string {
43
+ const cleaned = options.stripAtPrefix === false ? input.trim() : stripAtPathPrefix(input);
44
+ const expanded = expandTilde(cleaned);
45
+ return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(cwd, expanded);
46
+ }
47
+
48
+ export function isPathInside(basePath: string, candidatePath: string): boolean {
49
+ const relative = path.relative(path.resolve(basePath), path.resolve(candidatePath));
50
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
51
+ }
52
+
53
+ export function safeResolveInside(basePath: string, reference: string): string {
54
+ const base = path.resolve(basePath);
55
+ const candidate = resolveUserPath(reference, base);
56
+ if (!isPathInside(base, candidate)) throw new Error(`Path escapes base directory: ${reference}`);
57
+ return candidate;
58
+ }
59
+
60
+ export function formatUserPath(filePath: string, homeDir = os.homedir()): string {
61
+ const normalized = path.resolve(filePath);
62
+ const home = path.resolve(homeDir);
63
+ if (normalized === home) return "~";
64
+ if (normalized.startsWith(`${home}${path.sep}`)) return `~/${normalized.slice(home.length + 1).split(path.sep).join("/")}`;
65
+ return normalized;
66
+ }
package/src/process.ts ADDED
@@ -0,0 +1,152 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFile, spawn, type ChildProcessByStdio } from "node:child_process";
5
+ import type { Readable } from "node:stream";
6
+
7
+ export const ANSI_ESCAPE_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
8
+
9
+ export type CommandResult = {
10
+ ok: boolean;
11
+ stdout: string;
12
+ stderr: string;
13
+ exitCode?: number;
14
+ signal?: NodeJS.Signals | null;
15
+ error?: string;
16
+ timedOut?: boolean;
17
+ };
18
+
19
+ export type RunCommandOptions = {
20
+ cwd?: string;
21
+ timeoutMs?: number;
22
+ env?: NodeJS.ProcessEnv;
23
+ maxStdoutChars?: number;
24
+ maxStderrChars?: number;
25
+ };
26
+
27
+ export type AbortableProcess = ChildProcessByStdio<null, Readable, Readable> & {
28
+ abortProcessGroup?: () => void;
29
+ abortReleaseStep?: () => void;
30
+ };
31
+
32
+ export function shellQuote(value: string): string {
33
+ return `'${value.replace(/'/g, `'\\''`)}'`;
34
+ }
35
+
36
+ export function stripAnsi(input: string): string {
37
+ return input.replace(ANSI_ESCAPE_RE, "");
38
+ }
39
+
40
+ export function resolveExecutableFromPath(binName: string, envPath = process.env.PATH ?? ""): string | undefined {
41
+ const candidates = os.platform() === "win32" && !binName.toLowerCase().endsWith(".exe") ? [binName, `${binName}.exe`] : [binName];
42
+ for (const dir of envPath.split(path.delimiter).filter(Boolean)) {
43
+ for (const name of candidates) {
44
+ const candidate = path.join(dir, name);
45
+ if (fs.existsSync(candidate)) return candidate;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ export async function commandExists(command: string, args: string[] = ["--version"], timeoutMs = 3000): Promise<boolean> {
52
+ const result = await runCommand(command, args, { timeoutMs });
53
+ return result.ok;
54
+ }
55
+
56
+ function trimBuffer(value: string, maxChars: number | undefined): string {
57
+ if (!maxChars || value.length <= maxChars) return value;
58
+ return value.slice(-maxChars);
59
+ }
60
+
61
+ export function runCommand(command: string, args: string[] = [], options: RunCommandOptions = {}): Promise<CommandResult> {
62
+ return new Promise((resolve) => {
63
+ const child = execFile(command, args, { cwd: options.cwd, env: options.env, timeout: options.timeoutMs }, (error, stdout, stderr) => {
64
+ const exitCode = error && "code" in error && typeof error.code === "number" ? error.code : error ? 1 : 0;
65
+ resolve({
66
+ ok: !error,
67
+ stdout: trimBuffer(String(stdout ?? ""), options.maxStdoutChars),
68
+ stderr: trimBuffer(String(stderr ?? ""), options.maxStderrChars),
69
+ exitCode,
70
+ signal: error && "signal" in error ? (error.signal as NodeJS.Signals | null) : null,
71
+ error: error instanceof Error ? error.message : undefined,
72
+ timedOut: error && "killed" in error ? Boolean(error.killed) : false,
73
+ });
74
+ });
75
+ child.on("error", (error) => {
76
+ resolve({ ok: false, stdout: "", stderr: "", error: error.message, exitCode: 1 });
77
+ });
78
+ });
79
+ }
80
+
81
+ export function runShellCommand(cwd: string, command: string, options: RunCommandOptions = {}): Promise<CommandResult> {
82
+ return runCommand("bash", ["-lc", command], { ...options, cwd });
83
+ }
84
+
85
+ export function runLiveShellCommand(args: {
86
+ cwd: string;
87
+ command: string;
88
+ onChunk: (chunk: string) => void;
89
+ onChild?: (child: AbortableProcess) => void;
90
+ timeoutMs?: number;
91
+ detached?: boolean;
92
+ }): Promise<CommandResult & { output: string; aborted: boolean }> {
93
+ return new Promise((resolve) => {
94
+ const child = spawn("bash", ["-lc", args.command], {
95
+ cwd: args.cwd,
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ detached: args.detached ?? true,
98
+ }) as AbortableProcess;
99
+ let output = "";
100
+ let aborted = false;
101
+ let settled = false;
102
+ let timer: NodeJS.Timeout | undefined;
103
+
104
+ const abort = () => {
105
+ aborted = true;
106
+ try {
107
+ if (child.pid && child.pid > 0) process.kill(-child.pid, "SIGINT");
108
+ } catch {
109
+ child.kill("SIGINT");
110
+ }
111
+ setTimeout(() => {
112
+ if (child.exitCode === null && child.signalCode === null) {
113
+ try {
114
+ if (child.pid && child.pid > 0) process.kill(-child.pid, "SIGTERM");
115
+ } catch {
116
+ child.kill("SIGTERM");
117
+ }
118
+ }
119
+ }, 1500).unref();
120
+ };
121
+ child.abortProcessGroup = abort;
122
+ child.abortReleaseStep = abort;
123
+
124
+ const finish = (result: CommandResult & { output: string; aborted: boolean }) => {
125
+ if (settled) return;
126
+ settled = true;
127
+ if (timer) clearTimeout(timer);
128
+ resolve(result);
129
+ };
130
+
131
+ if (args.timeoutMs && args.timeoutMs > 0) {
132
+ timer = setTimeout(() => {
133
+ abort();
134
+ finish({ ok: false, stdout: output, stderr: "", output, aborted: true, timedOut: true });
135
+ }, args.timeoutMs);
136
+ }
137
+
138
+ args.onChild?.(child);
139
+ child.stdout.on("data", (d) => {
140
+ const chunk = String(d);
141
+ output += chunk;
142
+ args.onChunk(chunk);
143
+ });
144
+ child.stderr.on("data", (d) => {
145
+ const chunk = String(d);
146
+ output += chunk;
147
+ args.onChunk(chunk);
148
+ });
149
+ child.on("error", (error) => finish({ ok: false, stdout: output, stderr: error.message, output, aborted, error: error.message }));
150
+ child.on("close", (code, signal) => finish({ ok: code === 0 && !aborted, stdout: output, stderr: "", output, aborted, exitCode: code ?? undefined, signal }));
151
+ });
152
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export type RunLog = {
5
+ id: string;
6
+ startedAt: string;
7
+ cwd: string;
8
+ chunks: string[];
9
+ saved: boolean;
10
+ };
11
+
12
+ export type RunLogEntry = {
13
+ file: string;
14
+ title: string;
15
+ mtimeMs: number;
16
+ };
17
+
18
+ export function sanitizeLogId(value: string): string {
19
+ return value.replace(/[^0-9A-Za-z._-]/g, "-");
20
+ }
21
+
22
+ export function createRunLog(cwd: string, now = new Date()): RunLog {
23
+ const startedAt = now.toISOString();
24
+ return { id: sanitizeLogId(startedAt), startedAt, cwd, chunks: [], saved: false };
25
+ }
26
+
27
+ export function appendRunLog(runLog: RunLog, chunk: string): void {
28
+ runLog.chunks.push(chunk);
29
+ }
30
+
31
+ export function saveRunLog(runLog: RunLog, args: { logDir: string; title: string; status: string; summary?: string; now?: Date }): string | undefined {
32
+ if (runLog.saved) return undefined;
33
+ runLog.saved = true;
34
+ try {
35
+ mkdirSync(args.logDir, { recursive: true });
36
+ const filePath = join(args.logDir, `${runLog.id}-${sanitizeLogId(args.status)}.log`);
37
+ const content = [
38
+ args.title,
39
+ `started_at=${runLog.startedAt}`,
40
+ `finished_at=${(args.now ?? new Date()).toISOString()}`,
41
+ `status=${args.status}`,
42
+ `cwd=${runLog.cwd}`,
43
+ args.summary ? `summary=${args.summary.replace(/\r?\n/g, " | ")}` : undefined,
44
+ "",
45
+ "--- output ---",
46
+ runLog.chunks.join(""),
47
+ ].filter((line): line is string => line !== undefined).join("\n");
48
+ writeFileSync(filePath, content, "utf8");
49
+ return filePath;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ export function listRunLogs(logDir: string): RunLogEntry[] {
56
+ if (!existsSync(logDir)) return [];
57
+ return readdirSync(logDir)
58
+ .filter((file) => file.endsWith(".log"))
59
+ .map((file) => {
60
+ const filePath = join(logDir, file);
61
+ const stat = statSync(filePath);
62
+ return { file: filePath, title: file.replace(/\.log$/, ""), mtimeMs: stat.mtimeMs };
63
+ })
64
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
65
+ }
@@ -0,0 +1,17 @@
1
+ export type TextToolResult<T = unknown> = {
2
+ content: Array<{ type: "text"; text: string }>;
3
+ details?: T;
4
+ isError?: boolean;
5
+ };
6
+
7
+ export function textToolResult<T = unknown>(text: string, details?: T, options: { isError?: boolean } = {}): TextToolResult<T> {
8
+ return {
9
+ content: [{ type: "text", text }],
10
+ ...(details === undefined ? {} : { details }),
11
+ ...(options.isError === undefined ? {} : { isError: options.isError }),
12
+ };
13
+ }
14
+
15
+ export function jsonToolResult<T = unknown>(payload: T, options: { space?: number; isError?: boolean } = {}): TextToolResult<T> {
16
+ return textToolResult(JSON.stringify(payload, null, options.space ?? 2), payload, { isError: options.isError });
17
+ }
@@ -0,0 +1,48 @@
1
+ export function isCtrlO(data: string): boolean {
2
+ const key = data.toLowerCase();
3
+ return data === "\x0f" || key === "ctrl+o" || data === "\x1b[111;5u" || data === "\x1b[27;5;111~";
4
+ }
5
+
6
+ export function isCtrlC(data: string): boolean {
7
+ const key = data.toLowerCase();
8
+ return data === "\x03" || key === "ctrl+c" || data === "\x1b[99;5u" || data === "\x1b[27;5;99~";
9
+ }
10
+
11
+ export function appendDisplayChunk(lines: string[], chunk: string): void {
12
+ if (lines.length === 0) lines.push("");
13
+
14
+ for (let i = 0; i < chunk.length; i++) {
15
+ const char = chunk[i];
16
+ if (char === "\r") {
17
+ if (chunk[i + 1] === "\n") {
18
+ lines.push("");
19
+ i++;
20
+ } else {
21
+ lines[lines.length - 1] = "";
22
+ }
23
+ continue;
24
+ }
25
+ if (char === "\n") {
26
+ lines.push("");
27
+ continue;
28
+ }
29
+ lines[lines.length - 1] += char;
30
+ }
31
+ }
32
+
33
+ export function outputLinesFromDisplay(lines: string[]): string[] {
34
+ const visible = lines.slice();
35
+ while (visible.length > 0 && visible[visible.length - 1] === "") visible.pop();
36
+ return visible;
37
+ }
38
+
39
+ export function formatElapsed(startMs: number, nowMs = Date.now()): string {
40
+ const seconds = Math.max(0, Math.floor((nowMs - startMs) / 1000));
41
+ const minutes = Math.floor(seconds / 60);
42
+ const remainder = seconds % 60;
43
+ return minutes > 0 ? `${minutes}m${String(remainder).padStart(2, "0")}s` : `${remainder}s`;
44
+ }
45
+
46
+ export function truncateLine(line: string, width: number): string {
47
+ return line.length > width ? `${line.slice(0, Math.max(0, width - 1))}…` : line;
48
+ }