pi-unsloth-webtools 0.8.0 → 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.
package/settings.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { agentDir } from "./agent-dir.ts";
4
+ import type { FetchTransport } from "./web-fetch.ts";
4
5
 
5
6
  async function readJson(file: string): Promise<Record<string, unknown> | undefined> {
6
7
  try {
@@ -49,6 +50,23 @@ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean
49
50
  return undefined;
50
51
  }
51
52
 
53
+ function pickStringArray(data: Record<string, unknown>, paths: string[][]): string[] | undefined {
54
+ for (const path of paths) {
55
+ let cur: unknown = data;
56
+ for (const key of path) {
57
+ if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
58
+ else {
59
+ cur = undefined;
60
+ break;
61
+ }
62
+ }
63
+ if (!Array.isArray(cur)) continue;
64
+ const values = cur.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
65
+ if (values.length) return values;
66
+ }
67
+ return undefined;
68
+ }
69
+
52
70
  function pickString(data: Record<string, unknown>, paths: string[][]): string | undefined {
53
71
  for (const path of paths) {
54
72
  let cur: unknown = data;
@@ -94,11 +112,27 @@ const ALLOW_LOCAL_FILES_PATHS: string[][] = [
94
112
  ["webFetch", "allowLocalFiles"],
95
113
  ];
96
114
 
97
- const JINA_API_KEY_PATHS: string[][] = [
98
- ["unslothWebTools", "jinaApiKey"],
99
- ["webRender", "jinaApiKey"],
115
+ const TRANSPORT_PATHS: string[][] = [
116
+ ["unslothWebTools", "transport"],
117
+ ["webFetch", "transport"],
118
+ ];
119
+
120
+ const TRANSPORT_VALUES: ReadonlySet<string> = new Set(["tls-first", "direct-first", "off"]);
121
+
122
+ const LIGHTPANDA_ENABLED_PATHS: string[][] = [
123
+ ["unslothWebTools", "lightpandaEnabled"],
124
+ ["webRender", "lightpandaEnabled"],
125
+ ];
126
+
127
+ const LIGHTPANDA_PATH_PATHS: string[][] = [
128
+ ["unslothWebTools", "lightpandaPath"],
129
+ ["webRender", "lightpandaPath"],
100
130
  ];
101
131
 
132
+ const LIGHTPANDA_COMMAND_PATHS: string[][] = [
133
+ ["unslothWebTools", "lightpandaCommand"],
134
+ ["webRender", "lightpandaCommand"],
135
+ ];
102
136
  function clampMaxResults(value: number): number {
103
137
  return Math.min(20, Math.max(1, value));
104
138
  }
@@ -151,11 +185,13 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
151
185
  timeoutMs?: number;
152
186
  allowPrivateAddresses: boolean;
153
187
  allowLocalFiles: boolean;
188
+ transport: FetchTransport;
154
189
  }> {
155
190
  let maxChars: number | undefined;
156
191
  let timeoutMs: number | undefined;
157
192
  let allowPrivateAddresses = true;
158
193
  let allowLocalFiles = true;
194
+ let transport: FetchTransport = "tls-first";
159
195
  for (const data of await settingsEntries(cwd)) {
160
196
  const c = pickNumber(data, FETCH_MAX_CHARS_PATHS);
161
197
  if (c !== undefined && c > 0) maxChars = c;
@@ -165,17 +201,27 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
165
201
  if (p !== undefined) allowPrivateAddresses = p;
166
202
  const l = pickBoolean(data, ALLOW_LOCAL_FILES_PATHS);
167
203
  if (l !== undefined) allowLocalFiles = l;
204
+ const mode = pickString(data, TRANSPORT_PATHS);
205
+ if (mode !== undefined && TRANSPORT_VALUES.has(mode)) transport = mode as FetchTransport;
168
206
  }
169
- return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles };
207
+ return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles, transport };
170
208
  }
171
209
 
172
- export async function loadJinaApiKey(cwd?: string): Promise<string | undefined> {
173
- let result: string | undefined;
210
+ export async function loadLightpandaSettings(cwd?: string): Promise<{
211
+ enabled: boolean;
212
+ binaryPath: string | null;
213
+ command: string[] | null;
214
+ }> {
215
+ let enabled = true;
216
+ let binaryPath: string | null = null;
217
+ let command: string[] | null = null;
174
218
  for (const data of await settingsEntries(cwd)) {
175
- const candidate = pickString(data, JINA_API_KEY_PATHS);
176
- if (candidate !== undefined) result = candidate;
219
+ const e = pickBoolean(data, LIGHTPANDA_ENABLED_PATHS);
220
+ if (e !== undefined) enabled = e;
221
+ const p = pickString(data, LIGHTPANDA_PATH_PATHS);
222
+ if (p !== undefined) binaryPath = p;
223
+ const c = pickStringArray(data, LIGHTPANDA_COMMAND_PATHS);
224
+ if (c !== undefined) command = c;
177
225
  }
178
- if (result !== undefined) return result;
179
- const env = process.env.JINA_API_KEY?.trim();
180
- return env ? env : undefined;
226
+ return { enabled, binaryPath, command };
181
227
  }
package/tls-fetch.ts ADDED
@@ -0,0 +1,205 @@
1
+ import { MAX_SIGNAL_TIMEOUT_MS } from "./web-access.ts";
2
+
3
+ const MODULE_NAME = "wreq-js";
4
+ const DEFAULT_PROFILE = "chrome_145";
5
+ const DEFAULT_OS = "windows";
6
+ const TRANSPORT_CACHE_LIMIT = 8;
7
+
8
+ const HOP_HEADER_EXCLUSIONS = new Set(["host", "user-agent", "accept-encoding", "content-length"]);
9
+ const PDF_MAGIC = Buffer.from("%PDF-");
10
+ const MAGIC_HEAD_BYTES = 1024;
11
+ const SKIPPED_MAGIC_PREFIX_BYTES = new Set([0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20]);
12
+ const BOM_PREFIXES = [
13
+ Buffer.from([0xff, 0xfe, 0x00, 0x00]),
14
+ Buffer.from([0x00, 0x00, 0xfe, 0xff]),
15
+ Buffer.from([0xff, 0xfe]),
16
+ Buffer.from([0xfe, 0xff]),
17
+ Buffer.from([0xef, 0xbb, 0xbf]),
18
+ ];
19
+
20
+ function looksLikePdf(head: Buffer): boolean {
21
+ let start = 0;
22
+ while (start < head.length && SKIPPED_MAGIC_PREFIX_BYTES.has(head[start])) start++;
23
+ for (const bom of BOM_PREFIXES) {
24
+ if (head.subarray(start).length >= bom.length && head.subarray(start, start + bom.length).equals(bom)) {
25
+ start += bom.length;
26
+ while (start < head.length && SKIPPED_MAGIC_PREFIX_BYTES.has(head[start])) start++;
27
+ break;
28
+ }
29
+ }
30
+ const magic = head.subarray(start);
31
+ return magic.length >= PDF_MAGIC.length && magic.subarray(0, PDF_MAGIC.length).equals(PDF_MAGIC);
32
+ }
33
+
34
+ interface ImpersonationResponse {
35
+ status: number;
36
+ url: string;
37
+ headers: { get(name: string): string | null };
38
+ body: AsyncIterable<Uint8Array> | null;
39
+ }
40
+
41
+ interface ImpersonationTransport {
42
+ close?(): Promise<void>;
43
+ }
44
+
45
+ interface ImpersonationModule {
46
+ createTransport(options: Record<string, unknown>): Promise<ImpersonationTransport>;
47
+ fetch(input: string, init: Record<string, unknown>): Promise<ImpersonationResponse>;
48
+ }
49
+
50
+ export type ImpersonationLoader = () => Promise<ImpersonationModule | null>;
51
+
52
+ export interface TlsHopOptions {
53
+ url: URL;
54
+ pinnedIp: string;
55
+ family: number;
56
+ timeoutMs: number;
57
+ signal?: AbortSignal;
58
+ maxBytes: number;
59
+ maxPdfBytes?: number;
60
+ profile?: string;
61
+ extraHeaders?: Record<string, string>;
62
+ loader?: ImpersonationLoader;
63
+ }
64
+
65
+ export interface TlsHopResponse {
66
+ status: number;
67
+ headers: Record<string, string>;
68
+ body: Buffer;
69
+ truncated: boolean;
70
+ }
71
+
72
+ let cachedModule: Promise<ImpersonationModule | null> | null = null;
73
+
74
+ async function loadImpersonationModule(): Promise<ImpersonationModule | null> {
75
+ cachedModule ??= (async () => {
76
+ try {
77
+ return (await import(MODULE_NAME)) as unknown as ImpersonationModule;
78
+ } catch {
79
+ return null;
80
+ }
81
+ })();
82
+ return cachedModule;
83
+ }
84
+
85
+ const transports = new Map<string, Promise<ImpersonationTransport>>();
86
+
87
+ function transportKey(options: TlsHopOptions): string {
88
+ return `${options.profile ?? DEFAULT_PROFILE}|${options.url.hostname}|${options.pinnedIp}|${options.family}`;
89
+ }
90
+
91
+ function evictTransports(): void {
92
+ while (transports.size > TRANSPORT_CACHE_LIMIT) {
93
+ const oldestKey = transports.keys().next().value as string | undefined;
94
+ if (oldestKey === undefined) return;
95
+ const oldest = transports.get(oldestKey);
96
+ transports.delete(oldestKey);
97
+ void oldest
98
+ ?.then((transport) => transport.close?.())
99
+ .catch(() => {});
100
+ }
101
+ }
102
+
103
+ async function transportFor(
104
+ module: ImpersonationModule,
105
+ options: TlsHopOptions,
106
+ ): Promise<ImpersonationTransport> {
107
+ const key = transportKey(options);
108
+ const existing = transports.get(key);
109
+ if (existing) return existing;
110
+ const created = module
111
+ .createTransport({
112
+ resolve: { [options.url.hostname]: options.pinnedIp },
113
+ browser: options.profile ?? DEFAULT_PROFILE,
114
+ os: DEFAULT_OS,
115
+ })
116
+ .catch((error: unknown) => {
117
+ transports.delete(key);
118
+ throw error;
119
+ });
120
+ transports.set(key, created);
121
+ evictTransports();
122
+ return created;
123
+ }
124
+
125
+ async function readCapped(
126
+ body: AsyncIterable<Uint8Array> | null,
127
+ maxBytes: number,
128
+ maxPdfBytes: number,
129
+ ): Promise<{ buffer: Buffer; truncated: boolean }> {
130
+ if (!body) return { buffer: Buffer.alloc(0), truncated: false };
131
+ const chunks: Buffer[] = [];
132
+ let total = 0;
133
+ let truncated = false;
134
+ let head = Buffer.alloc(0);
135
+ let limit = maxBytes;
136
+ for await (const chunk of body) {
137
+ const buffer = Buffer.from(chunk);
138
+ if (head.length < MAGIC_HEAD_BYTES) {
139
+ head = Buffer.concat([head, buffer.subarray(0, MAGIC_HEAD_BYTES - head.length)]);
140
+ if (limit === maxBytes && looksLikePdf(head)) limit = maxPdfBytes;
141
+ }
142
+ if (total + buffer.length > limit) {
143
+ const take = buffer.subarray(0, limit - total);
144
+ chunks.push(take);
145
+ total += take.length;
146
+ truncated = true;
147
+ break;
148
+ }
149
+ chunks.push(buffer);
150
+ total += buffer.length;
151
+ }
152
+ return { buffer: Buffer.concat(chunks), truncated };
153
+ }
154
+
155
+ function failureMessage(error: unknown, caller: AbortSignal | undefined, attempt: AbortSignal): string {
156
+ if (caller?.aborted) return "cancelled";
157
+ if (attempt.aborted) return "timed out";
158
+ if (error instanceof Error && error.name === "TimeoutError") return "timed out";
159
+ if (error instanceof Error && error.name === "AbortError") return "cancelled";
160
+ return error instanceof Error ? error.message : String(error);
161
+ }
162
+
163
+ function hopHeaders(extraHeaders: Record<string, string> | undefined): Record<string, string> {
164
+ const headers: Record<string, string> = {};
165
+ for (const [key, value] of Object.entries(extraHeaders ?? {})) {
166
+ if (HOP_HEADER_EXCLUSIONS.has(key.toLowerCase())) continue;
167
+ headers[key] = value;
168
+ }
169
+ return headers;
170
+ }
171
+
172
+ export async function impersonatedRequest(options: TlsHopOptions): Promise<TlsHopResponse | null> {
173
+ const load = options.loader ?? loadImpersonationModule;
174
+ const module = await load();
175
+ if (!module) return null;
176
+ const transport = await transportFor(module, options);
177
+ const timeoutMs = Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, Math.floor(options.timeoutMs)));
178
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
179
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
180
+ let response: ImpersonationResponse;
181
+ try {
182
+ response = await module.fetch(options.url.toString(), {
183
+ transport,
184
+ redirect: "manual",
185
+ signal,
186
+ headers: hopHeaders(options.extraHeaders),
187
+ });
188
+ } catch (error) {
189
+ throw new Error(failureMessage(error, options.signal, signal));
190
+ }
191
+ if (response.status < 200) return null;
192
+ const { buffer, truncated } = await readCapped(
193
+ response.body,
194
+ options.maxBytes,
195
+ options.maxPdfBytes ?? options.maxBytes,
196
+ );
197
+ const headers: Record<string, string> = {};
198
+ const contentType = response.headers.get("content-type");
199
+ if (contentType) headers["content-type"] = contentType;
200
+ if (response.status >= 300 && response.status < 400) {
201
+ const location = response.headers.get("location");
202
+ if (location) headers.location = location;
203
+ }
204
+ return { status: response.status, headers, body: buffer, truncated };
205
+ }
package/web-fetch.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  stripIpv6Brackets,
24
24
  type WebsitePolicy,
25
25
  } from "./web-access.ts";
26
+ import { impersonatedRequest, type TlsHopOptions, type TlsHopResponse } from "./tls-fetch.ts";
26
27
  import { collapseWhitespace, decodeHtmlEntities, feedHtml, htmlToMarkdown, visibleChars } from "./html-to-md.ts";
27
28
  import type { AttrDict } from "./html-to-md.ts";
28
29
  import { INVALID_CHARREFS } from "./entities.ts";
@@ -157,8 +158,8 @@ export class FetchTimeoutError extends Error {
157
158
  }
158
159
  const FETCH_CANCELLED_MESSAGE = "Failed to fetch URL: cancelled.";
159
160
  const FETCH_TIMEOUT_MESSAGE = "Failed to fetch URL: timed out.";
160
- const TRUNCATED_BODY_SUFFIX = "... (page truncated at the download limit)";
161
- const TRUNCATED_BODY_NOTICE = "\n\n" + TRUNCATED_BODY_SUFFIX;
161
+ export const TRUNCATED_BODY_SUFFIX = "... (page truncated at the download limit)";
162
+ export const TRUNCATED_BODY_NOTICE = "\n\n" + TRUNCATED_BODY_SUFFIX;
162
163
 
163
164
  function fetchErrorMessage(err: unknown): string {
164
165
  if (err instanceof FetchCancelledError) return FETCH_CANCELLED_MESSAGE;
@@ -188,6 +189,7 @@ export interface FetchPageOptions {
188
189
  maxBytes?: number;
189
190
  maxPdfBytes?: number;
190
191
  allowPrivateAddresses?: boolean;
192
+ transport?: FetchTransport;
191
193
  allowLocalFiles?: boolean;
192
194
  seams?: FetchSeams;
193
195
  rawFetch?: (url: string, options: RawFetchOptions) => Promise<RawFetchResult>;
@@ -208,9 +210,13 @@ export interface ResolvedHost {
208
210
  alternates?: { ip: string; family: number }[];
209
211
  }
210
212
 
213
+ export type FetchTransport = "tls-first" | "direct-first" | "off";
214
+
215
+ type TransportKind = "direct" | "tls";
211
216
  export interface FetchSeams {
212
217
  resolve?: (hostname: string, signal?: AbortSignal, allowPrivateAddresses?: boolean) => Promise<ResolvedHost>;
213
218
  request?: (opts: HopOptions) => Promise<HopResponse>;
219
+ impersonate?: (options: TlsHopOptions) => Promise<TlsHopResponse | null>;
214
220
  }
215
221
 
216
222
  export interface HopOptions {
@@ -236,6 +242,7 @@ export interface RawFetchOptions {
236
242
  maxBytes?: number;
237
243
  maxPdfBytes?: number;
238
244
  allowPrivateAddresses?: boolean;
245
+ transport?: FetchTransport;
239
246
  seams?: FetchSeams;
240
247
  }
241
248
 
@@ -903,6 +910,10 @@ export async function fetchUrlRaw(
903
910
  const resolveHost = seams.resolve ?? resolveAndValidateHost;
904
911
  const allowPrivateAddresses = options.allowPrivateAddresses ?? true;
905
912
  const performRequest = seams.request ?? requestHop;
913
+ const transportMode = options.transport ?? "tls-first";
914
+ const performImpersonation =
915
+ transportMode === "off" ? null : (seams.impersonate ?? (seams.request ? null : impersonatedRequest));
916
+ const directFirst = transportMode === "direct-first" || performImpersonation === null;
906
917
  const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
907
918
  const abortController = new AbortController();
908
919
  const waitMs = clampedRemainingMs(deadline, now);
@@ -937,6 +948,7 @@ export async function fetchUrlRaw(
937
948
  let pinnedFamily = resolved.family;
938
949
  let alternates: { ip: string; family: number }[] = resolved.alternates ?? [];
939
950
  let alternateIndex = 0;
951
+ const triedTransports = new Set<string>();
940
952
  const userAgent = randomUserAgent();
941
953
  for (let hop = 0; hop < MAX_REQUESTS; hop++) {
942
954
  const budgetResult = checkBudget();
@@ -960,34 +972,67 @@ export async function fetchUrlRaw(
960
972
  }
961
973
  headers["Host"] = hostHeader;
962
974
  const inactivity = remainingMs(deadline, now);
963
- let response: HopResponse;
964
- try {
965
- response = await performRequest({
966
- url: parsed,
967
- pinnedIp,
968
- family: pinnedFamily,
969
- headers,
970
- maxBytes,
971
- maxPdfBytes,
972
- inactivityMs: inactivity,
973
- deadlineMs: deadline,
974
- nowMs: now,
975
- signal,
976
- });
977
- } catch (err) {
978
- const message = err instanceof Error ? err.message : "";
979
- if (message === "cancelled" || message === "timed out") return emptyResult(fetchErrorMessage(err));
980
- if (
981
- !(err instanceof FetchCancelledError) &&
982
- !(err instanceof FetchTimeoutError) &&
983
- alternateIndex < alternates.length
984
- ) {
975
+ const order: TransportKind[] = socksProxyForUrl(parsed) !== null
976
+ ? ["direct"]
977
+ : directFirst
978
+ ? ["direct", "tls"]
979
+ : ["tls", "direct"];
980
+ let response: HopResponse | null = null;
981
+ let failure: unknown = null;
982
+ for (const kind of order) {
983
+ const key = `${kind}|${currentUrl}|${pinnedIp}`;
984
+ if (triedTransports.has(key)) continue;
985
+ triedTransports.add(key);
986
+ let attempt: HopResponse | null;
987
+ try {
988
+ if (kind === "tls") {
989
+ const impersonate = performImpersonation;
990
+ if (impersonate === null) continue;
991
+ attempt = await impersonate({
992
+ url: parsed,
993
+ pinnedIp,
994
+ family: pinnedFamily,
995
+ timeoutMs: remainingMs(deadline, now),
996
+ signal,
997
+ maxBytes,
998
+ maxPdfBytes,
999
+ extraHeaders: options.extraHeaders,
1000
+ });
1001
+ } else {
1002
+ attempt = await performRequest({
1003
+ url: parsed,
1004
+ pinnedIp,
1005
+ family: pinnedFamily,
1006
+ headers,
1007
+ maxBytes,
1008
+ maxPdfBytes,
1009
+ inactivityMs: inactivity,
1010
+ deadlineMs: deadline,
1011
+ nowMs: now,
1012
+ signal,
1013
+ });
1014
+ }
1015
+ } catch (err) {
1016
+ const message = err instanceof Error ? err.message : "";
1017
+ if (message === "cancelled" || message === "timed out") return emptyResult(fetchErrorMessage(err));
1018
+ if (err instanceof FetchCancelledError || err instanceof FetchTimeoutError) {
1019
+ return emptyResult(fetchErrorMessage(err));
1020
+ }
1021
+ failure = err;
1022
+ continue;
1023
+ }
1024
+ if (attempt === null) continue;
1025
+ if (response === null || (attempt.status < 400 && response.status >= 400)) response = attempt;
1026
+ if (attempt.status < 400) break;
1027
+ }
1028
+ if (response === null) {
1029
+ if (alternateIndex < alternates.length) {
985
1030
  const next = alternates[alternateIndex++];
986
1031
  pinnedIp = next.ip;
987
1032
  pinnedFamily = next.family;
988
1033
  continue;
989
1034
  }
990
- return emptyResult(fetchErrorMessage(err));
1035
+ return emptyResult(fetchErrorMessage(failure ?? new Error("no transport could reach the target")));
991
1036
  }
992
1037
 
993
1038
  if (response.status >= 300 && response.status < 400) {
@@ -1256,7 +1301,7 @@ function extractPageMeta(html: string): PageMeta {
1256
1301
  return meta;
1257
1302
  }
1258
1303
 
1259
- function pagePrefixedMarkdown(html: string): string {
1304
+ export function pagePrefixedMarkdown(html: string): string {
1260
1305
  const meta = extractPageMeta(html);
1261
1306
  const lines: string[] = [];
1262
1307
  if (meta.title) lines.push(`Title: ${meta.title}`);
@@ -1446,6 +1491,7 @@ export async function fetchPageOutcome(
1446
1491
  maxBytes: options.maxBytes,
1447
1492
  maxPdfBytes: options.maxPdfBytes,
1448
1493
  seams: options.seams,
1494
+ transport: options.transport,
1449
1495
  };
1450
1496
  const useCache = !options.seams && !options.rawFetch;
1451
1497
  const githubRaw = githubRawContentUrl(url);
package/config-ui.ts DELETED
@@ -1,105 +0,0 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
3
- import { readConfig, type Config } from "./config.ts";
4
-
5
- export type ConfigToggleKey = "webRenderEnabled";
6
-
7
- export interface ConfigRow {
8
- key: ConfigToggleKey;
9
- label: string;
10
- hint: string;
11
- enabled: boolean;
12
- }
13
-
14
- export function configRows(config: Config): ConfigRow[] {
15
- return [
16
- {
17
- key: "webRenderEnabled",
18
- label: "JavaScript rendering",
19
- hint: "Jina Reader fallback",
20
- enabled: config.webRenderEnabled !== false,
21
- },
22
- ];
23
- }
24
-
25
- function padRow(theme: Theme, innerWidth: number, content: string): string {
26
- const padded = content + " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
27
- return theme.fg("border", "│") + padded + theme.fg("border", "│");
28
- }
29
-
30
- export class WebToolsConfigOverlay {
31
- private rows: ConfigRow[] = [];
32
- private selected = 0;
33
-
34
- constructor(
35
- private readonly opts: {
36
- tui: { requestRender(force?: boolean): void };
37
- theme: Theme;
38
- done: () => void;
39
- onToggle: (key: ConfigToggleKey) => Promise<void>;
40
- },
41
- ) {}
42
-
43
- async load(): Promise<void> {
44
- this.rows = configRows(await readConfig());
45
- }
46
-
47
- private runToggle(row: ConfigRow): void {
48
- this.opts.tui.requestRender(true);
49
- void this.opts
50
- .onToggle(row.key)
51
- .then(async () => {
52
- this.rows = configRows(await readConfig());
53
- this.opts.tui.requestRender(true);
54
- })
55
- .catch((error: unknown) => {
56
- console.error("Failed to toggle web tools setting:", error);
57
- });
58
- }
59
-
60
- private toggleSelected(): void {
61
- const row = this.rows[this.selected];
62
- if (!row) return;
63
- row.enabled = !row.enabled;
64
- this.runToggle(row);
65
- }
66
-
67
- handleInput(data: string): void {
68
- if (matchesKey(data, Key.up) || data === "k") {
69
- this.selected = (this.selected + this.rows.length - 1) % this.rows.length;
70
- return;
71
- }
72
- if (matchesKey(data, Key.down) || data === "j") {
73
- this.selected = (this.selected + 1) % this.rows.length;
74
- return;
75
- }
76
- if (matchesKey(data, Key.space) || matchesKey(data, Key.enter) || data === " " || data === "\r" || data === "\n") {
77
- this.toggleSelected();
78
- return;
79
- }
80
- if (matchesKey(data, Key.escape) || data === "q") {
81
- this.opts.done();
82
- }
83
- }
84
-
85
- invalidate(): void {}
86
-
87
- render(width: number): string[] {
88
- const theme = this.opts.theme;
89
- const innerWidth = width - 2;
90
- const lines: string[] = [];
91
- lines.push(theme.fg("border", `╭${"─".repeat(innerWidth)}╮`));
92
- lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", theme.bold("Web Tools Config"))}`));
93
- lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
94
- this.rows.forEach((row, index) => {
95
- const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
96
- const box = row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
97
- const label = index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
98
- lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", `— ${row.hint}`)}`));
99
- });
100
- lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
101
- lines.push(padRow(theme, innerWidth, theme.fg("dim", " ↑↓ navigate · space toggle · q close")));
102
- lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
103
- return lines;
104
- }
105
- }