@zeldrisho/pi-web-fetch 0.8.2 → 0.9.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.
@@ -5,15 +5,20 @@ import { requestPinned, responseHeader } from "./network-transport";
5
5
 
6
6
  /** Maximum number of HTTP redirects to follow before aborting. */
7
7
  export const FETCH_MAX_REDIRECTS = 5;
8
+
8
9
  const MAX_ORIGIN_CONCURRENCY = 4;
10
+
9
11
  const MAX_RETRIES = 2;
12
+
10
13
  const MAX_RETRY_DELAY_MS = 10_000;
14
+
11
15
  const BASE_RETRY_DELAY_MS = 250;
12
16
 
13
17
  interface OriginState {
14
18
  active: number;
15
19
  waiting: Array<() => void>;
16
20
  }
21
+
17
22
  const origins = new Map<string, OriginState>();
18
23
 
19
24
  /** Optional dependencies for redirect handling (used for testing). */
@@ -28,21 +33,27 @@ export interface RedirectDependencies {
28
33
  sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
29
34
  }
30
35
 
36
+ /** Creates the consistent abort error used by redirect and retry coordination. */
31
37
  function abortError(): Error {
32
38
  const error = new Error("Operation aborted.");
33
39
  error.name = "AbortError";
40
+
34
41
  return error;
35
42
  }
36
43
 
44
+ /** Reserves an origin-concurrency slot and returns a function that releases it. */
37
45
  async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() => void> {
38
46
  const state = origins.get(origin) ?? { active: 0, waiting: [] };
39
47
  origins.set(origin, state);
40
48
  let reserved = false;
49
+
41
50
  const release = () => {
42
51
  state.active -= 1;
43
52
  state.waiting.shift()?.();
53
+
44
54
  if (state.active === 0 && state.waiting.length === 0) origins.delete(origin);
45
55
  };
56
+
46
57
  if (state.active >= MAX_ORIGIN_CONCURRENCY) {
47
58
  await new Promise<void>((resolve, reject) => {
48
59
  const enter = () => {
@@ -51,11 +62,14 @@ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() =>
51
62
  reserved = true;
52
63
  resolve();
53
64
  };
65
+
54
66
  const cancel = () => {
55
67
  const index = state.waiting.indexOf(enter);
68
+
56
69
  if (index >= 0) state.waiting.splice(index, 1);
57
70
  reject(abortError());
58
71
  };
72
+
59
73
  if (signal.aborted) cancel();
60
74
  else {
61
75
  state.waiting.push(enter);
@@ -63,40 +77,52 @@ async function acquireOrigin(origin: string, signal: AbortSignal): Promise<() =>
63
77
  }
64
78
  });
65
79
  }
80
+
66
81
  if (signal.aborted) {
67
82
  if (reserved) release();
68
83
  throw abortError();
69
84
  }
85
+
70
86
  if (!reserved) state.active += 1;
87
+
71
88
  return release;
72
89
  }
73
90
 
91
+ /** Waits for a retry delay while allowing the caller to cancel immediately. */
74
92
  async function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
75
93
  await new Promise<void>((resolve, reject) => {
76
94
  const timer = setTimeout(done, milliseconds);
95
+
77
96
  function done(): void {
78
97
  signal.removeEventListener("abort", cancel);
79
98
  resolve();
80
99
  }
100
+
81
101
  function cancel(): void {
82
102
  clearTimeout(timer);
83
103
  reject(abortError());
84
104
  }
105
+
85
106
  if (signal.aborted) cancel();
86
107
  else signal.addEventListener("abort", cancel, { once: true });
87
108
  });
88
109
  }
89
110
 
111
+ /** Parses a Retry-After header into a bounded delay in milliseconds. */
90
112
  function retryAfterMilliseconds(value: string | undefined, now = Date.now()): number | undefined {
91
113
  if (!value) return undefined;
92
114
  const seconds = Number(value.trim());
115
+
93
116
  if (Number.isFinite(seconds) && seconds >= 0)
94
117
  return Math.min(seconds * 1_000, MAX_RETRY_DELAY_MS);
95
118
  const date = Date.parse(value);
119
+
96
120
  if (!Number.isFinite(date)) return undefined;
121
+
97
122
  return Math.min(Math.max(0, date - now), MAX_RETRY_DELAY_MS);
98
123
  }
99
124
 
125
+ /** Sends one origin-limited request and retries transient rate-limit responses. */
100
126
  async function coordinatedRequest(
101
127
  target: ValidatedTarget,
102
128
  signal: AbortSignal,
@@ -107,17 +133,22 @@ async function coordinatedRequest(
107
133
  dependencies.request ??
108
134
  ((value, requestSignal, requestHeaders) =>
109
135
  requestPinned(value, requestSignal, { headers: requestHeaders }));
136
+
110
137
  const random = dependencies.random ?? Math.random;
111
138
  const sleep = dependencies.sleep ?? defaultSleep;
139
+
112
140
  for (let attempt = 0; ; attempt += 1) {
113
141
  const release = await acquireOrigin(target.url.origin, signal);
114
142
  let response: IncomingMessage;
143
+
115
144
  try {
116
145
  response = await request(target, signal, headers);
117
146
  } finally {
118
147
  release();
119
148
  }
149
+
120
150
  const status = response.statusCode ?? 0;
151
+
121
152
  if (![429, 503].includes(status) || attempt >= MAX_RETRIES) return response;
122
153
  // The retry decision uses headers only. Drop an untrusted error body so it cannot
123
154
  // consume unbounded bandwidth while the caller is backing off.
@@ -146,10 +177,13 @@ export async function requestFollowingRedirects(
146
177
  for (let redirects = 0; redirects <= FETCH_MAX_REDIRECTS; redirects += 1) {
147
178
  const response = await coordinatedRequest(target, signal, requestHeaders, dependencies);
148
179
  const status = response.statusCode ?? 0;
180
+
149
181
  if (![301, 302, 303, 307, 308].includes(status)) return { target, response };
150
182
 
151
183
  const location = responseHeader(response, "location");
184
+
152
185
  if (!location) throw new Error("web_fetch received a redirect without a Location header.");
186
+
153
187
  if (redirects === FETCH_MAX_REDIRECTS)
154
188
  throw new Error("web_fetch followed too many redirects.");
155
189
  response.resume();
@@ -157,5 +191,6 @@ export async function requestFollowingRedirects(
157
191
  // Validators describe the originally cached resource and must not leak to a redirect target.
158
192
  requestHeaders = {};
159
193
  }
194
+
160
195
  throw new Error("web_fetch followed too many redirects.");
161
196
  }
@@ -20,6 +20,7 @@ const encoder = new TextEncoder();
20
20
  function abortedError(): Error {
21
21
  const error = new Error("Operation aborted.");
22
22
  error.name = "AbortError";
23
+
23
24
  return error;
24
25
  }
25
26
 
@@ -39,6 +40,7 @@ function responseTooLargeMessage(
39
40
  const size = sizeIsExact
40
41
  ? `is ${formatSize(receivedBytes)}`
41
42
  : `has reached at least ${formatSize(receivedBytes)}`;
43
+
42
44
  return [
43
45
  `web_fetch response ${size}, exceeding the ${formatSize(maxBytes)} raw download limit.`,
44
46
  "maxCharacters only controls returned output.",
@@ -63,7 +65,9 @@ async function requestOnce(
63
65
  headers: Readonly<Record<string, string>>,
64
66
  ): Promise<IncomingMessage> {
65
67
  const family = isIP(address);
68
+
66
69
  if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
70
+
67
71
  const lookup: LookupFunction = (_hostname, options, callback) => {
68
72
  // Match dns.lookup's asynchronous callback contract. Calling back inline
69
73
  // lets an immediately unreachable address emit a socket error before
@@ -73,18 +77,24 @@ async function requestOnce(
73
77
  else callback(null, address, family);
74
78
  });
75
79
  };
80
+
76
81
  const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
82
+
77
83
  return await new Promise((resolve, reject) => {
78
84
  const controller = new AbortController();
79
85
  let attemptExpired = false;
86
+
80
87
  const timer = setTimeout(() => {
81
88
  attemptExpired = true;
82
89
  controller.abort();
83
90
  }, attemptTimeoutMs);
91
+
84
92
  const forwardAbort = () => controller.abort();
93
+
85
94
  if (signal.aborted) controller.abort();
86
95
  else signal.addEventListener("abort", forwardAbort, { once: true });
87
96
  let settled = false;
97
+
88
98
  const finish = (callback: () => void) => {
89
99
  if (settled) return;
90
100
  settled = true;
@@ -92,8 +102,10 @@ async function requestOnce(
92
102
  signal.removeEventListener("abort", forwardAbort);
93
103
  callback();
94
104
  };
105
+
95
106
  const unreachableError = () =>
96
107
  new Error(`web_fetch could not reach ${address} within ${attemptTimeoutMs} ms.`);
108
+
97
109
  const outgoing = request(
98
110
  target.url,
99
111
  {
@@ -114,6 +126,7 @@ async function requestOnce(
114
126
  } else finish(() => resolve(response));
115
127
  },
116
128
  );
129
+
117
130
  outgoing.once("error", (error) => {
118
131
  // The per-attempt deadline surfaces as a raw AbortError from the HTTP
119
132
  // client; report it as an unreachable address instead so the next
@@ -148,14 +161,17 @@ export async function requestPinned(
148
161
  const attemptTimeoutMs = options.attemptTimeoutMs ?? CONNECT_ATTEMPT_TIMEOUT_MS;
149
162
  const addresses = target.addresses?.length ? target.addresses : [target.address];
150
163
  let lastError: unknown;
164
+
151
165
  for (const address of addresses) {
152
166
  try {
153
167
  return await requestOnce(target, address, signal, attemptTimeoutMs, options.headers ?? {});
154
168
  } catch (error) {
155
169
  lastError = error;
170
+
156
171
  if (signal.aborted) throw error;
157
172
  }
158
173
  }
174
+
159
175
  throw lastError ?? new Error("web_fetch could not connect.");
160
176
  }
161
177
 
@@ -168,9 +184,29 @@ export async function requestPinned(
168
184
  */
169
185
  export function responseHeader(response: IncomingMessage, name: string): string | undefined {
170
186
  const value = response.headers[name];
187
+
171
188
  return Array.isArray(value) ? value[0] : value;
172
189
  }
173
190
 
191
+ export function responseHeaderValues(response: IncomingMessage, name: string): string[] {
192
+ const values: string[] = [];
193
+ const lowerName = name.toLowerCase();
194
+
195
+ const rawHeaders = response.rawHeaders ?? [];
196
+
197
+ for (let index = 0; index + 1 < rawHeaders.length; index += 2) {
198
+ if (rawHeaders[index].toLowerCase() === lowerName) {
199
+ values.push(rawHeaders[index + 1]);
200
+ }
201
+ }
202
+
203
+ if (values.length > 0) return values;
204
+
205
+ const value = response.headers[name];
206
+
207
+ return value === undefined ? [] : Array.isArray(value) ? value : [value];
208
+ }
209
+
174
210
  /**
175
211
  * Read and buffer the complete response body.
176
212
  *
@@ -189,27 +225,33 @@ export async function readResponseBytes(
189
225
  signal?: AbortSignal,
190
226
  ): Promise<Uint8Array> {
191
227
  const declared = Number(responseHeader(response, "content-length"));
228
+
192
229
  if (Number.isFinite(declared) && declared > maxBytes) {
193
230
  response.destroy();
194
231
  throw new Error(responseTooLargeMessage(declared, maxBytes, true));
195
232
  }
233
+
196
234
  // Once response headers arrive the connect deadline and caller signal are no
197
235
  // longer wired to the socket, so a stalled body would otherwise hang the
198
236
  // fetch forever. Keep the caller signal attached for the whole body read and
199
237
  // drop the socket when it fires.
200
238
  const forwardAbort = () => response.destroy();
239
+
201
240
  if (signal?.aborted) response.destroy();
202
241
  else signal?.addEventListener("abort", forwardAbort, { once: true });
203
242
  const chunks: Uint8Array[] = [];
204
243
  let total = 0;
244
+
205
245
  try {
206
246
  for await (const value of response) {
207
247
  const chunk = value instanceof Uint8Array ? value : encoder.encode(value);
208
248
  total += chunk.byteLength;
249
+
209
250
  if (total > maxBytes) {
210
251
  response.destroy();
211
252
  throw new Error(responseTooLargeMessage(total, maxBytes, false));
212
253
  }
254
+
213
255
  chunks.push(chunk);
214
256
  }
215
257
  } catch (error) {
@@ -218,15 +260,18 @@ export async function readResponseBytes(
218
260
  } finally {
219
261
  signal?.removeEventListener("abort", forwardAbort);
220
262
  }
263
+
221
264
  // Destroying the socket can end the stream without an error; detect a
222
265
  // mid-read abort here as well so truncated bodies never look complete.
223
266
  if (signal?.aborted) throw abortedError();
224
267
  const output = new Uint8Array(total);
225
268
  let offset = 0;
269
+
226
270
  for (const chunk of chunks) {
227
271
  output.set(chunk, offset);
228
272
  offset += chunk.byteLength;
229
273
  }
274
+
230
275
  return output;
231
276
  }
232
277
 
@@ -242,6 +287,7 @@ export async function readResponseBytes(
242
287
  */
243
288
  export function decodeResponse(bytes: Uint8Array, contentTypeHeader: string): string {
244
289
  const charset = contentTypeHeader.match(/(?:^|;)\s*charset\s*=\s*["']?([^;"'\s]+)/i)?.[1];
290
+
245
291
  try {
246
292
  return new TextDecoder(charset || "utf-8").decode(bytes);
247
293
  } catch {
package/src/network.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  type ResolveAddresses,
6
6
  type ValidatedTarget,
7
7
  } from "./network-policy";
8
+
8
9
  export {
9
10
  decodeResponse,
10
11
  FETCH_MAX_BYTES,
package/src/outline.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/;
2
+
2
3
  const MAX_FALLBACK_HEADING_CHARACTERS = 60;
4
+
3
5
  export const MAX_OUTLINE_HEADINGS = 12;
6
+
4
7
  export const MAX_OUTLINE_HEADING_CHARACTERS = 120;
5
8
 
6
9
  /** One bounded heading exposed as untrusted document-shape metadata. */
@@ -41,15 +44,20 @@ interface MarkdownFence {
41
44
  length: number;
42
45
  }
43
46
 
47
+ /** Tracks whether the current Markdown line opens or closes a fenced code block. */
44
48
  function updateFence(line: string, fence: MarkdownFence | undefined): MarkdownFence | undefined {
45
49
  const match = FENCE.exec(line);
50
+
46
51
  if (!match) return fence;
47
52
  const marker = match[1];
53
+
48
54
  if (!fence) return { character: marker[0], length: marker.length };
55
+
49
56
  const closes =
50
57
  marker[0] === fence.character &&
51
58
  marker.length >= fence.length &&
52
59
  !line.slice(match[0].length).trim();
60
+
53
61
  return closes ? undefined : fence;
54
62
  }
55
63
 
@@ -66,15 +74,20 @@ function isSpaceOrTab(character: string | undefined): boolean {
66
74
  /** Parses the bounded ATX prefix with a linear scan to avoid backtracking on remote input. */
67
75
  function parseAtxHeading(line: string): ParsedAtxHeading | undefined {
68
76
  let index = 0;
77
+
69
78
  while (index < 3 && isSpaceOrTab(line[index])) index += 1;
79
+
70
80
  if (isSpaceOrTab(line[index])) return undefined;
71
81
 
72
82
  const markerStart = index;
83
+
73
84
  while (line[index] === "#") index += 1;
74
85
  const level = index - markerStart;
86
+
75
87
  if (level < 1 || level > 6 || !isSpaceOrTab(line[index])) return undefined;
76
88
 
77
89
  while (isSpaceOrTab(line[index])) index += 1;
90
+
78
91
  return { level, text: line.slice(index) };
79
92
  }
80
93
 
@@ -86,6 +99,7 @@ function parseAtxHeading(line: string): ParsedAtxHeading | undefined {
86
99
  */
87
100
  function countWords(value: string): number {
88
101
  const trimmed = value.trim();
102
+
89
103
  return trimmed ? trimmed.split(/\s+/).length : 0;
90
104
  }
91
105
 
@@ -97,10 +111,12 @@ function countWords(value: string): number {
97
111
  */
98
112
  function countDocumentWords(lines: string[]): number {
99
113
  let fence: MarkdownFence | undefined;
114
+
100
115
  return lines.reduce((total, line) => {
101
116
  const wasInsideFence = Boolean(fence);
102
117
  fence = updateFence(line, fence);
103
118
  const heading = wasInsideFence || fence ? undefined : parseAtxHeading(line);
119
+
104
120
  return total + countWords(heading ? cleanHeading(heading.text) : line);
105
121
  }, 0);
106
122
  }
@@ -113,12 +129,16 @@ function countDocumentWords(lines: string[]): number {
113
129
  */
114
130
  function cleanHeading(value: string): string {
115
131
  let contentEnd = value.length;
132
+
116
133
  while (contentEnd > 0 && isSpaceOrTab(value[contentEnd - 1])) contentEnd -= 1;
117
134
 
118
135
  let markerStart = contentEnd;
136
+
119
137
  while (markerStart > 0 && value[markerStart - 1] === "#") markerStart -= 1;
138
+
120
139
  if (markerStart < contentEnd && markerStart > 0 && isSpaceOrTab(value[markerStart - 1])) {
121
140
  contentEnd = markerStart - 1;
141
+
122
142
  while (contentEnd > 0 && isSpaceOrTab(value[contentEnd - 1])) contentEnd -= 1;
123
143
  }
124
144
 
@@ -139,16 +159,21 @@ function collectAtxHeadings(lines: string[]): HeadingCollection {
139
159
  for (const [lineNumber, line] of lines.entries()) {
140
160
  const wasInsideFence = Boolean(fence);
141
161
  fence = updateFence(line, fence);
162
+
142
163
  if (wasInsideFence || fence) continue;
143
164
  const heading = parseAtxHeading(line);
165
+
144
166
  if (!heading) continue;
145
167
  const text = cleanHeading(heading.text);
168
+
146
169
  if (!text) continue;
147
170
  total += 1;
171
+
148
172
  if (locations.length <= MAX_OUTLINE_HEADINGS) {
149
173
  locations.push({ level: heading.level, text, line: lineNumber, inferred: false });
150
174
  }
151
175
  }
176
+
152
177
  return { locations, total };
153
178
  }
154
179
 
@@ -162,6 +187,7 @@ function collectAtxHeadings(lines: string[]): HeadingCollection {
162
187
  function looksLikeFallbackHeading(line: string, nextLine: string): boolean {
163
188
  if (/^\s{4,}/.test(line)) return false;
164
189
  const candidate = line.trim();
190
+
165
191
  if (
166
192
  !candidate ||
167
193
  candidate.length > MAX_FALLBACK_HEADING_CHARACTERS ||
@@ -173,8 +199,10 @@ function looksLikeFallbackHeading(line: string, nextLine: string): boolean {
173
199
  ) {
174
200
  return false;
175
201
  }
202
+
176
203
  if (!/^[A-Z]/.test(candidate) && !/[`()._]/.test(candidate)) return false;
177
204
  const following = nextLine.trim();
205
+
178
206
  return following.length > MAX_FALLBACK_HEADING_CHARACTERS || /[.!?]$/.test(following);
179
207
  }
180
208
 
@@ -190,25 +218,32 @@ function collectFallbackHeadings(lines: string[]): HeadingCollection {
190
218
  const nextNonEmptyIndexes = new Int32Array(lines.length);
191
219
  nextNonEmptyIndexes.fill(-1);
192
220
  let nextNonEmptyIndex = -1;
221
+
193
222
  for (let lineNumber = lines.length - 1; lineNumber >= 0; lineNumber -= 1) {
194
223
  nextNonEmptyIndexes[lineNumber] = nextNonEmptyIndex;
224
+
195
225
  if (lines[lineNumber].trim()) nextNonEmptyIndex = lineNumber;
196
226
  }
227
+
197
228
  let fence: MarkdownFence | undefined;
198
229
 
199
230
  for (const [lineNumber, line] of lines.entries()) {
200
231
  const wasInsideFence = Boolean(fence);
201
232
  fence = updateFence(line, fence);
233
+
202
234
  if (wasInsideFence || fence) continue;
203
235
  const followingIndex = nextNonEmptyIndexes[lineNumber];
204
236
  const following = followingIndex === -1 ? "" : lines[followingIndex];
237
+
205
238
  if (looksLikeFallbackHeading(line, following)) {
206
239
  total += 1;
240
+
207
241
  if (locations.length <= MAX_OUTLINE_HEADINGS) {
208
242
  locations.push({ level: 2, text: cleanHeading(line), line: lineNumber, inferred: true });
209
243
  }
210
244
  }
211
245
  }
246
+
212
247
  return { locations, total };
213
248
  }
214
249
 
@@ -222,8 +257,10 @@ export function createDocumentOutline(markdown: string): DocumentOutline {
222
257
  const lines = markdown.split("\n");
223
258
  const explicit = collectAtxHeadings(lines);
224
259
  const collection = explicit.total > 0 ? explicit : collectFallbackHeadings(lines);
260
+
225
261
  const headings = collection.locations.slice(0, MAX_OUTLINE_HEADINGS).map((heading, index) => {
226
262
  const next = collection.locations[index + 1]?.line ?? lines.length;
263
+
227
264
  return {
228
265
  level: heading.level,
229
266
  text: heading.text,
@@ -231,6 +268,7 @@ export function createDocumentOutline(markdown: string): DocumentOutline {
231
268
  inferred: heading.inferred,
232
269
  };
233
270
  });
271
+
234
272
  return {
235
273
  totalWords: countDocumentWords(lines),
236
274
  totalHeadings: collection.total,
package/src/redact.ts CHANGED
@@ -17,14 +17,17 @@ const SENSITIVE_QUERY_KEYS = new Set([
17
17
  "token",
18
18
  ]);
19
19
 
20
+ /** Replaces sensitive query-parameter values and reports whether anything changed. */
20
21
  function redactSensitiveParams(params: URLSearchParams): boolean {
21
22
  let redacted = false;
23
+
22
24
  for (const key of params.keys()) {
23
25
  if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
24
26
  params.set(key, "REDACTED");
25
27
  redacted = true;
26
28
  }
27
29
  }
30
+
28
31
  return redacted;
29
32
  }
30
33
 
@@ -37,6 +40,7 @@ function redactSensitiveParams(params: URLSearchParams): boolean {
37
40
  */
38
41
  export function redactUrlForDisplay(value: string | URL): string {
39
42
  let url: URL;
43
+
40
44
  try {
41
45
  url = value instanceof URL ? new URL(value.href) : new URL(value);
42
46
  } catch {
@@ -46,9 +50,12 @@ export function redactUrlForDisplay(value: string | URL): string {
46
50
  url.username = "";
47
51
  url.password = "";
48
52
  redactSensitiveParams(url.searchParams);
53
+
49
54
  if (url.hash.length > 1) {
50
55
  const fragmentParams = new URLSearchParams(url.hash.slice(1));
56
+
51
57
  if (redactSensitiveParams(fragmentParams)) url.hash = fragmentParams.toString();
52
58
  }
59
+
53
60
  return url.href;
54
61
  }
package/src/render.ts CHANGED
@@ -13,14 +13,17 @@ export function formatCollapsibleOutput(
13
13
  theme: RenderTheme,
14
14
  ): string {
15
15
  const lines = output.replace(/\r\n/g, "\n").split("\n");
16
+
16
17
  while (lines.at(-1) === "") lines.pop();
17
18
  const totalLines = lines.length;
18
19
  const visibleLines = expanded ? lines : lines.slice(0, COLLAPSED_LINES);
19
20
  let text = visibleLines.map((line) => theme.fg("toolOutput", line)).join("\n");
20
21
 
21
22
  const remaining = totalLines - visibleLines.length;
23
+
22
24
  if (remaining > 0) {
23
25
  text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
24
26
  }
27
+
25
28
  return `\n${text}`;
26
29
  }