@ramarivera/pi-television 0.0.2 → 0.0.4

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
@@ -1,11 +1,55 @@
1
1
  # @ramarivera/pi-television
2
2
 
3
- Pi extension that replaces the fuzzy file finder with television (tv) for faster, non-blocking file search
3
+ Pi extension that keeps Pi's native file picking UX while replacing the default `@file` search path with a faster background television-style search.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- pi install npm:@ramarivera/pi-television@0.0.1
8
+ pi install npm:@ramarivera/pi-television@0.0.4
9
+ ```
10
+
11
+ ## Modes
12
+
13
+ ### Default: native live picker
14
+
15
+ By default, typing `@` in Pi keeps using Pi's native picker UI, but the suggestions come from this extension's background file search instead of launching the full-screen `tv` interface.
16
+
17
+ ### Optional: select dialog mode
18
+
19
+ If you want the simpler fallback flow, create `.pi/television.json` in your project:
20
+
21
+ ```json
22
+ {
23
+ "mode": "select-dialog"
24
+ }
25
+ ```
26
+
27
+ That mode uses background search plus a native Pi select dialog when you trigger `@`.
28
+
29
+ ## Config
30
+
31
+ Project config lives at:
32
+
33
+ ```text
34
+ .pi/television.json
35
+ ```
36
+
37
+ You can also set a user-level default at:
38
+
39
+ ```text
40
+ ~/.pi/agent/television.json
41
+ ```
42
+
43
+ Project config overrides user config.
44
+
45
+ Supported fields:
46
+
47
+ ```json
48
+ {
49
+ "mode": "native-live",
50
+ "maxResults": 20,
51
+ "refreshMs": 10000
52
+ }
9
53
  ```
10
54
 
11
55
  ## Local Development
@@ -37,4 +81,3 @@ Before the first publish, configure npm trusted publishing:
37
81
  - environment: blank unless the workflow is changed to require one
38
82
 
39
83
  No `NPM_TOKEN` is required for trusted publishing.
40
-
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ramarivera/pi-television",
3
- "version": "0.0.2",
4
- "description": "Pi extension that replaces the fuzzy file finder with television (tv) for faster, non-blocking file search",
3
+ "version": "0.0.4",
4
+ "description": "Pi extension that powers native Pi @file picking with background television-style search",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Ramiro Rivera",
package/src/extension.ts CHANGED
@@ -1,11 +1,21 @@
1
- import { spawn } from "node:child_process";
2
- import { relative, resolve } from "node:path";
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, relative, resolve } from "node:path";
3
4
  import type {
4
5
  ExtensionAPI,
5
6
  ExtensionContext,
6
7
  TerminalInputHandler,
7
8
  } from "@earendil-works/pi-coding-agent";
8
- import { decodeKittyPrintable, Key, matchesKey } from "@earendil-works/pi-tui";
9
+ import type {
10
+ AutocompleteItem,
11
+ AutocompleteProvider,
12
+ } from "@earendil-works/pi-tui";
13
+ import {
14
+ decodeKittyPrintable,
15
+ fuzzyFilter,
16
+ Key,
17
+ matchesKey,
18
+ } from "@earendil-works/pi-tui";
9
19
 
10
20
  export type ExtensionInfo = {
11
21
  name: string;
@@ -17,121 +27,296 @@ export type TelevisionPickResult =
17
27
  | { status: "cancelled" }
18
28
  | { status: "failed"; message: string };
19
29
 
20
- export type TelevisionRunnerOptions = {
30
+ export type TelevisionMode = "native-live" | "select-dialog";
31
+
32
+ export type TelevisionConfig = {
33
+ mode?: TelevisionMode;
34
+ maxResults?: number;
35
+ refreshMs?: number;
36
+ };
37
+
38
+ export type TelevisionResolvedConfig = {
39
+ mode: TelevisionMode;
40
+ maxResults: number;
41
+ refreshMs: number;
42
+ };
43
+
44
+ export type TelevisionSearchResult = {
45
+ path: string;
46
+ label?: string;
47
+ description?: string;
48
+ };
49
+
50
+ export type TelevisionSearchOptions = {
21
51
  cwd: string;
22
52
  query?: string;
23
53
  signal?: AbortSignal;
54
+ maxResults?: number;
55
+ refreshMs?: number;
24
56
  };
25
57
 
26
- export type TelevisionRunner = (
27
- options: TelevisionRunnerOptions,
28
- ) => Promise<TelevisionPickResult>;
58
+ export type TelevisionSearcher = (
59
+ options: TelevisionSearchOptions,
60
+ ) => Promise<TelevisionSearchResult[]>;
61
+
62
+ export type TelevisionConfigLoader = (
63
+ cwd: string,
64
+ ) => Promise<TelevisionResolvedConfig>;
29
65
 
30
66
  export type TelevisionExtensionOptions = {
31
67
  commandName?: string;
32
68
  shortcut?: string;
33
- runner?: TelevisionRunner;
69
+ searcher?: TelevisionSearcher;
70
+ configLoader?: TelevisionConfigLoader;
34
71
  };
35
72
 
36
73
  const STATUS_KEY = "pi-television";
37
74
  const DEFAULT_COMMAND_NAME = "television";
38
75
  const DEFAULT_SHORTCUT = "@";
76
+ const DEFAULT_MAX_RESULTS = 20;
77
+ const DEFAULT_REFRESH_MS = 10_000;
39
78
 
40
79
  export const extensionInfo: ExtensionInfo = {
41
80
  name: "television",
42
81
  description:
43
- "Pi extension that replaces the fuzzy file finder with television (tv) for faster, non-blocking file search",
82
+ "Pi extension that powers native @file picking with background television-style search",
83
+ };
84
+
85
+ const defaultResolvedConfig: TelevisionResolvedConfig = {
86
+ mode: "native-live",
87
+ maxResults: DEFAULT_MAX_RESULTS,
88
+ refreshMs: DEFAULT_REFRESH_MS,
44
89
  };
45
90
 
46
- function cleanSelectedPath(stdout: string): string | null {
47
- const selected = stdout
91
+ type FileIndexCache = {
92
+ loadedAt: number;
93
+ entries?: string[];
94
+ pending?: Promise<string[]>;
95
+ };
96
+
97
+ function uniq(values: string[]): string[] {
98
+ const seen = new Set<string>();
99
+ const deduped: string[] = [];
100
+
101
+ for (const value of values) {
102
+ if (seen.has(value)) continue;
103
+ seen.add(value);
104
+ deduped.push(value);
105
+ }
106
+
107
+ return deduped;
108
+ }
109
+
110
+ function cleanPaths(stdout: string): string[] {
111
+ return stdout
48
112
  .split(/\r?\n/)
49
113
  .map((line) => line.trim())
50
- .find((line) => line.length > 0);
51
- return selected ?? null;
52
- }
53
-
54
- function buildTvArgs(query: string | undefined): string[] {
55
- const sourceCommand =
56
- "fd --type f --type d --hidden --follow --exclude .git --strip-cwd-prefix";
57
- const args = [
58
- "--source-command",
59
- sourceCommand,
60
- "--source-display",
61
- "{}",
62
- "--source-output",
63
- "{}",
64
- "--preview-command",
65
- "test -d {} && ls -la {} || sed -n '1,160p' {}",
66
- "--preview-header",
67
- "{}",
68
- ];
69
-
70
- if (query?.trim()) {
71
- args.push("--input", query.trim());
114
+ .filter((line) => line.length > 0);
115
+ }
116
+
117
+ function extractFileToken(textBeforeCursor: string): string | undefined {
118
+ const match = textBeforeCursor.match(/(?:^|[ \t])@([^\s@]*)$/);
119
+ return match?.[1];
120
+ }
121
+
122
+ function toAutocompleteItem(result: TelevisionSearchResult): AutocompleteItem {
123
+ return {
124
+ value: `@${result.path}`,
125
+ label: result.label ?? result.path,
126
+ description: result.description ?? result.path,
127
+ };
128
+ }
129
+
130
+ function normalizeTelevisionConfig(
131
+ config: TelevisionConfig | undefined,
132
+ ): TelevisionResolvedConfig {
133
+ return {
134
+ mode: config?.mode ?? defaultResolvedConfig.mode,
135
+ maxResults: config?.maxResults ?? defaultResolvedConfig.maxResults,
136
+ refreshMs: config?.refreshMs ?? defaultResolvedConfig.refreshMs,
137
+ };
138
+ }
139
+
140
+ async function loadConfigFile(
141
+ path: string,
142
+ ): Promise<TelevisionConfig | undefined> {
143
+ try {
144
+ const content = await readFile(path, "utf8");
145
+ const parsed = JSON.parse(content) as TelevisionConfig;
146
+ return parsed;
147
+ } catch (error) {
148
+ const nodeError = error as NodeJS.ErrnoException;
149
+ if (nodeError.code === "ENOENT") {
150
+ return undefined;
151
+ }
152
+ throw new Error(`television: failed to read ${path}: ${nodeError.message}`);
72
153
  }
154
+ }
155
+
156
+ export async function loadTelevisionConfig(
157
+ cwd: string,
158
+ ): Promise<TelevisionResolvedConfig> {
159
+ const globalConfig = await loadConfigFile(
160
+ resolve(homedir(), ".pi", "agent", "television.json"),
161
+ );
162
+ const projectConfig = await loadConfigFile(
163
+ resolve(cwd, ".pi", "television.json"),
164
+ );
73
165
 
74
- return args;
166
+ return normalizeTelevisionConfig({
167
+ ...globalConfig,
168
+ ...projectConfig,
169
+ });
75
170
  }
76
171
 
77
- export function runTelevision(
78
- options: TelevisionRunnerOptions,
79
- ): Promise<TelevisionPickResult> {
80
- return new Promise((resolvePick) => {
81
- if (options.signal?.aborted) {
82
- resolvePick({ status: "cancelled" });
83
- return;
172
+ export function rankTelevisionResults(
173
+ paths: string[],
174
+ query: string | undefined,
175
+ maxResults: number,
176
+ ): TelevisionSearchResult[] {
177
+ const trimmedQuery = query?.trim() ?? "";
178
+ const limitedMaxResults = Math.max(1, maxResults);
179
+
180
+ if (!trimmedQuery) {
181
+ return paths.slice(0, limitedMaxResults).map((path) => ({ path }));
182
+ }
183
+
184
+ const exactPrefixMatches = paths.filter((path) =>
185
+ path.startsWith(trimmedQuery),
186
+ );
187
+ const basenamePrefixMatches = paths.filter(
188
+ (path) =>
189
+ basename(path).startsWith(trimmedQuery) &&
190
+ !exactPrefixMatches.includes(path),
191
+ );
192
+ const fuzzyMatches = fuzzyFilter(paths, trimmedQuery, (path) => {
193
+ const name = basename(path);
194
+ return name === path ? path : `${name} ${path}`;
195
+ });
196
+
197
+ return uniq([
198
+ ...exactPrefixMatches,
199
+ ...basenamePrefixMatches,
200
+ ...fuzzyMatches,
201
+ ])
202
+ .slice(0, limitedMaxResults)
203
+ .map((path) => ({ path }));
204
+ }
205
+
206
+ export function createDefaultSearcher(pi: ExtensionAPI): TelevisionSearcher {
207
+ const cache = new Map<string, FileIndexCache>();
208
+
209
+ return async ({
210
+ cwd,
211
+ query,
212
+ signal,
213
+ maxResults = DEFAULT_MAX_RESULTS,
214
+ refreshMs = DEFAULT_REFRESH_MS,
215
+ }) => {
216
+ const now = Date.now();
217
+ const cached = cache.get(cwd);
218
+
219
+ if (cached?.entries && now - cached.loadedAt < refreshMs) {
220
+ return rankTelevisionResults(cached.entries, query, maxResults);
84
221
  }
85
222
 
86
- const child = spawn("tv", buildTvArgs(options.query), {
87
- cwd: options.cwd,
88
- stdio: ["inherit", "pipe", "inherit"],
89
- });
223
+ if (cached?.pending) {
224
+ const entries = await cached.pending;
225
+ return rankTelevisionResults(entries, query, maxResults);
226
+ }
90
227
 
91
- let stdout = "";
92
- let settled = false;
93
-
94
- const settle = (result: TelevisionPickResult) => {
95
- if (settled) return;
96
- settled = true;
97
- options.signal?.removeEventListener("abort", onAbort);
98
- resolvePick(result);
99
- };
100
-
101
- const onAbort = () => {
102
- child.kill("SIGTERM");
103
- settle({ status: "cancelled" });
104
- };
105
-
106
- options.signal?.addEventListener("abort", onAbort, { once: true });
107
- child.stdout?.setEncoding("utf8");
108
- child.stdout?.on("data", (chunk: string) => {
109
- stdout += chunk;
110
- });
111
- child.on("error", (error) => {
112
- settle({ status: "failed", message: error.message });
113
- });
114
- child.on("close", (code, signal) => {
115
- if (settled) return;
116
- if (signal) {
117
- settle({ status: "cancelled" });
118
- return;
119
- }
120
- if (code === 0) {
121
- const path = cleanSelectedPath(stdout);
122
- settle(path ? { status: "selected", path } : { status: "cancelled" });
123
- return;
228
+ const pending = (async () => {
229
+ const result = await pi.exec(
230
+ "fd",
231
+ [
232
+ "--type",
233
+ "f",
234
+ "--hidden",
235
+ "--follow",
236
+ "--exclude",
237
+ ".git",
238
+ "--strip-cwd-prefix",
239
+ ],
240
+ {
241
+ cwd,
242
+ signal,
243
+ timeout: 10_000,
244
+ },
245
+ );
246
+
247
+ if (result.code !== 0) {
248
+ const details = result.stderr.trim() || `exit code ${result.code}`;
249
+ throw new Error(`television: fd failed: ${details}`);
124
250
  }
125
- if (code === 130 || code === 1) {
126
- settle({ status: "cancelled" });
127
- return;
251
+
252
+ const entries = cleanPaths(result.stdout);
253
+ cache.set(cwd, { loadedAt: Date.now(), entries });
254
+ return entries;
255
+ })();
256
+
257
+ cache.set(cwd, { loadedAt: now, pending });
258
+
259
+ try {
260
+ const entries = await pending;
261
+ return rankTelevisionResults(entries, query, maxResults);
262
+ } catch (error) {
263
+ cache.delete(cwd);
264
+ throw error;
265
+ }
266
+ };
267
+ }
268
+
269
+ export function createTelevisionAutocompleteProvider(
270
+ current: AutocompleteProvider,
271
+ searcher: TelevisionSearcher,
272
+ cwd: string,
273
+ config: TelevisionResolvedConfig,
274
+ ): AutocompleteProvider {
275
+ return {
276
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
277
+ const line = lines[cursorLine] ?? "";
278
+ const textBeforeCursor = line.slice(0, cursorCol);
279
+ const token = extractFileToken(textBeforeCursor);
280
+
281
+ if (token === undefined) {
282
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
128
283
  }
129
- settle({
130
- status: "failed",
131
- message: `television exited with code ${code ?? "unknown"}`,
284
+
285
+ const results = await searcher({
286
+ cwd,
287
+ query: token,
288
+ signal: options.signal,
289
+ maxResults: config.maxResults,
290
+ refreshMs: config.refreshMs,
132
291
  });
133
- });
134
- });
292
+
293
+ if (options.signal.aborted || results.length === 0) {
294
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
295
+ }
296
+
297
+ return {
298
+ prefix: `@${token}`,
299
+ items: results.slice(0, config.maxResults).map(toAutocompleteItem),
300
+ };
301
+ },
302
+
303
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
304
+ return current.applyCompletion(
305
+ lines,
306
+ cursorLine,
307
+ cursorCol,
308
+ item,
309
+ prefix,
310
+ );
311
+ },
312
+
313
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
314
+ return (
315
+ current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
316
+ true
317
+ );
318
+ },
319
+ };
135
320
  }
136
321
 
137
322
  export function toEditorAttachmentPath(
@@ -161,17 +346,24 @@ function isBoundaryKey(data: string): boolean {
161
346
  );
162
347
  }
163
348
 
164
- async function pickFile(
349
+ async function findFiles(
165
350
  ctx: ExtensionContext,
166
- runner: TelevisionRunner,
351
+ searcher: TelevisionSearcher,
167
352
  query: string | undefined,
168
- ): Promise<TelevisionPickResult> {
169
- ctx.ui.setStatus(STATUS_KEY, "television: picking file");
170
- ctx.ui.setWorkingMessage("television is picking a file");
353
+ config: TelevisionResolvedConfig,
354
+ ): Promise<TelevisionSearchResult[]> {
355
+ ctx.ui.setStatus(STATUS_KEY, "television: finding files");
356
+ ctx.ui.setWorkingMessage("television is finding files");
171
357
  ctx.ui.setWorkingIndicator({ frames: ["tv"], intervalMs: 1000 });
172
358
 
173
359
  try {
174
- return await runner({ cwd: ctx.cwd, query, signal: ctx.signal });
360
+ return await searcher({
361
+ cwd: ctx.cwd,
362
+ query,
363
+ signal: ctx.signal,
364
+ maxResults: config.maxResults,
365
+ refreshMs: config.refreshMs,
366
+ });
175
367
  } finally {
176
368
  ctx.ui.setStatus(STATUS_KEY, undefined);
177
369
  ctx.ui.setWorkingMessage();
@@ -179,26 +371,43 @@ async function pickFile(
179
371
  }
180
372
  }
181
373
 
182
- async function pastePickedFile(
374
+ async function pickFileWithSelectDialog(
183
375
  ctx: ExtensionContext,
184
- runner: TelevisionRunner,
376
+ searcher: TelevisionSearcher,
185
377
  query: string | undefined,
378
+ config: TelevisionResolvedConfig,
186
379
  ): Promise<TelevisionPickResult> {
187
- const result = await pickFile(ctx, runner, query);
380
+ try {
381
+ const results = await findFiles(ctx, searcher, query, config);
188
382
 
189
- if (result.status === "selected") {
190
- ctx.ui.pasteToEditor(toEditorAttachmentPath(result.path, ctx.cwd));
191
- } else if (result.status === "failed") {
192
- ctx.ui.notify(`television failed: ${result.message}`, "error");
193
- }
383
+ if (results.length === 0) {
384
+ ctx.ui.notify("television: no matching files found", "warning");
385
+ return { status: "cancelled" };
386
+ }
387
+
388
+ const choice = await ctx.ui.select(
389
+ "television",
390
+ results.slice(0, config.maxResults).map((result) => result.path),
391
+ );
194
392
 
195
- return result;
393
+ if (!choice) {
394
+ return { status: "cancelled" };
395
+ }
396
+
397
+ ctx.ui.pasteToEditor(toEditorAttachmentPath(choice, ctx.cwd));
398
+ return { status: "selected", path: choice };
399
+ } catch (error) {
400
+ const message = error instanceof Error ? error.message : String(error);
401
+ ctx.ui.notify(message, "error");
402
+ return { status: "failed", message };
403
+ }
196
404
  }
197
405
 
198
406
  function installShortcut(
199
407
  ctx: ExtensionContext,
200
- runner: TelevisionRunner,
408
+ searcher: TelevisionSearcher,
201
409
  shortcut: string,
410
+ config: TelevisionResolvedConfig,
202
411
  ): void {
203
412
  let pickerOpen = false;
204
413
  let lastKeyWasBoundary = true;
@@ -218,7 +427,12 @@ function installShortcut(
218
427
  }
219
428
 
220
429
  pickerOpen = true;
221
- void pastePickedFile(ctx, runner, query).finally(() => {
430
+ void pickFileWithSelectDialog(
431
+ ctx,
432
+ searcher,
433
+ query || undefined,
434
+ config,
435
+ ).finally(() => {
222
436
  pickerOpen = false;
223
437
  lastKeyWasBoundary = true;
224
438
  });
@@ -232,20 +446,49 @@ function installShortcut(
232
446
  export function createExtension(options: TelevisionExtensionOptions = {}) {
233
447
  const commandName = options.commandName ?? DEFAULT_COMMAND_NAME;
234
448
  const shortcut = options.shortcut ?? DEFAULT_SHORTCUT;
235
- const runner = options.runner ?? runTelevision;
236
449
 
237
450
  return {
238
451
  name: extensionInfo.name,
239
452
  register(pi: ExtensionAPI): void {
240
- pi.on("session_start", (_event, ctx) => {
241
- installShortcut(ctx, runner, shortcut);
453
+ const searcher = options.searcher ?? createDefaultSearcher(pi);
454
+ const configLoader = options.configLoader ?? loadTelevisionConfig;
455
+ let config = defaultResolvedConfig;
456
+
457
+ pi.on("session_start", async (_event, ctx) => {
458
+ try {
459
+ config = await configLoader(ctx.cwd);
460
+ } catch (error) {
461
+ const message =
462
+ error instanceof Error ? error.message : String(error);
463
+ config = defaultResolvedConfig;
464
+ ctx.ui.notify(message, "error");
465
+ }
466
+
467
+ if (config.mode === "native-live") {
468
+ ctx.ui.addAutocompleteProvider((current) =>
469
+ createTelevisionAutocompleteProvider(
470
+ current,
471
+ searcher,
472
+ ctx.cwd,
473
+ config,
474
+ ),
475
+ );
476
+ return;
477
+ }
478
+
479
+ installShortcut(ctx, searcher, shortcut, config);
242
480
  });
243
481
 
244
482
  pi.registerCommand(commandName, {
245
483
  description:
246
- "Pick a file with television (tv) and insert it as an @file attachment",
484
+ "Find a file in the background and insert it as an @file attachment",
247
485
  handler: async (args, ctx) => {
248
- await pastePickedFile(ctx, runner, args.trim());
486
+ await pickFileWithSelectDialog(
487
+ ctx,
488
+ searcher,
489
+ args.trim() || undefined,
490
+ config,
491
+ );
249
492
  },
250
493
  });
251
494
  },
package/src/index.ts CHANGED
@@ -2,14 +2,23 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { createExtension } from "./extension.ts";
3
3
 
4
4
  export type {
5
+ TelevisionConfig,
6
+ TelevisionConfigLoader,
5
7
  TelevisionExtensionOptions,
8
+ TelevisionMode,
6
9
  TelevisionPickResult,
7
- TelevisionRunner,
8
- TelevisionRunnerOptions,
10
+ TelevisionResolvedConfig,
11
+ TelevisionSearcher,
12
+ TelevisionSearchOptions,
13
+ TelevisionSearchResult,
9
14
  } from "./extension.ts";
10
15
  export {
16
+ createDefaultSearcher,
11
17
  createExtension,
18
+ createTelevisionAutocompleteProvider,
12
19
  extensionInfo,
20
+ loadTelevisionConfig,
21
+ rankTelevisionResults,
13
22
  toEditorAttachmentPath,
14
23
  } from "./extension.ts";
15
24