@ramarivera/pi-television 0.0.3 → 0.0.5

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,13 +1,60 @@
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
9
  ```
10
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
+ "includeFolders": true,
51
+ "maxResults": 20,
52
+ "refreshMs": 10000
53
+ }
54
+ ```
55
+
56
+ `includeFolders` defaults to `true`, so folder paths are returned alongside files. Set it to `false` to restrict the picker to regular files only.
57
+
11
58
  ## Local Development
12
59
 
13
60
  This checkout is live-enabled for Pi through:
@@ -37,4 +84,3 @@ Before the first publish, configure npm trusted publishing:
37
84
  - environment: blank unless the workflow is changed to require one
38
85
 
39
86
  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.3",
4
- "description": "Pi extension that replaces the fuzzy file finder with television (tv) for faster, non-blocking file search",
3
+ "version": "0.0.5",
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,306 @@ 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
+ includeFolders?: boolean;
37
+ };
38
+
39
+ export type TelevisionResolvedConfig = {
40
+ mode: TelevisionMode;
41
+ maxResults: number;
42
+ refreshMs: number;
43
+ includeFolders: boolean;
44
+ };
45
+
46
+ export type TelevisionSearchResult = {
47
+ path: string;
48
+ label?: string;
49
+ description?: string;
50
+ };
51
+
52
+ export type TelevisionSearchOptions = {
21
53
  cwd: string;
22
54
  query?: string;
23
55
  signal?: AbortSignal;
56
+ maxResults?: number;
57
+ refreshMs?: number;
58
+ includeFolders?: boolean;
24
59
  };
25
60
 
26
- export type TelevisionRunner = (
27
- options: TelevisionRunnerOptions,
28
- ) => Promise<TelevisionPickResult>;
61
+ export type TelevisionSearcher = (
62
+ options: TelevisionSearchOptions,
63
+ ) => Promise<TelevisionSearchResult[]>;
64
+
65
+ export type TelevisionConfigLoader = (
66
+ cwd: string,
67
+ ) => Promise<TelevisionResolvedConfig>;
29
68
 
30
69
  export type TelevisionExtensionOptions = {
31
70
  commandName?: string;
32
71
  shortcut?: string;
33
- runner?: TelevisionRunner;
72
+ searcher?: TelevisionSearcher;
73
+ configLoader?: TelevisionConfigLoader;
34
74
  };
35
75
 
36
76
  const STATUS_KEY = "pi-television";
37
77
  const DEFAULT_COMMAND_NAME = "television";
38
78
  const DEFAULT_SHORTCUT = "@";
79
+ const DEFAULT_MAX_RESULTS = 20;
80
+ const DEFAULT_REFRESH_MS = 10_000;
39
81
 
40
82
  export const extensionInfo: ExtensionInfo = {
41
83
  name: "television",
42
84
  description:
43
- "Pi extension that replaces the fuzzy file finder with television (tv) for faster, non-blocking file search",
85
+ "Pi extension that powers native @file picking with background television-style search",
44
86
  };
45
87
 
46
- function cleanSelectedPath(stdout: string): string | null {
47
- const selected = stdout
88
+ const DEFAULT_INCLUDE_FOLDERS = true;
89
+
90
+ const defaultResolvedConfig: TelevisionResolvedConfig = {
91
+ mode: "native-live",
92
+ maxResults: DEFAULT_MAX_RESULTS,
93
+ refreshMs: DEFAULT_REFRESH_MS,
94
+ includeFolders: DEFAULT_INCLUDE_FOLDERS,
95
+ };
96
+
97
+ type FileIndexCache = {
98
+ loadedAt: number;
99
+ entries?: string[];
100
+ pending?: Promise<string[]>;
101
+ };
102
+
103
+ function uniq(values: string[]): string[] {
104
+ const seen = new Set<string>();
105
+ const deduped: string[] = [];
106
+
107
+ for (const value of values) {
108
+ if (seen.has(value)) continue;
109
+ seen.add(value);
110
+ deduped.push(value);
111
+ }
112
+
113
+ return deduped;
114
+ }
115
+
116
+ function cleanPaths(stdout: string): string[] {
117
+ return stdout
48
118
  .split(/\r?\n/)
49
119
  .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());
120
+ .filter((line) => line.length > 0);
121
+ }
122
+
123
+ function extractFileToken(textBeforeCursor: string): string | undefined {
124
+ const match = textBeforeCursor.match(/(?:^|[ \t])@([^\s@]*)$/);
125
+ return match?.[1];
126
+ }
127
+
128
+ function toAutocompleteItem(result: TelevisionSearchResult): AutocompleteItem {
129
+ return {
130
+ value: `@${result.path}`,
131
+ label: result.label ?? result.path,
132
+ description: result.description ?? result.path,
133
+ };
134
+ }
135
+
136
+ function normalizeTelevisionConfig(
137
+ config: TelevisionConfig | undefined,
138
+ ): TelevisionResolvedConfig {
139
+ return {
140
+ mode: config?.mode ?? defaultResolvedConfig.mode,
141
+ maxResults: config?.maxResults ?? defaultResolvedConfig.maxResults,
142
+ refreshMs: config?.refreshMs ?? defaultResolvedConfig.refreshMs,
143
+ includeFolders:
144
+ config?.includeFolders ?? defaultResolvedConfig.includeFolders,
145
+ };
146
+ }
147
+
148
+ async function loadConfigFile(
149
+ path: string,
150
+ ): Promise<TelevisionConfig | undefined> {
151
+ try {
152
+ const content = await readFile(path, "utf8");
153
+ const parsed = JSON.parse(content) as TelevisionConfig;
154
+ return parsed;
155
+ } catch (error) {
156
+ const nodeError = error as NodeJS.ErrnoException;
157
+ if (nodeError.code === "ENOENT") {
158
+ return undefined;
159
+ }
160
+ throw new Error(`television: failed to read ${path}: ${nodeError.message}`);
72
161
  }
162
+ }
163
+
164
+ export async function loadTelevisionConfig(
165
+ cwd: string,
166
+ ): Promise<TelevisionResolvedConfig> {
167
+ const globalConfig = await loadConfigFile(
168
+ resolve(homedir(), ".pi", "agent", "television.json"),
169
+ );
170
+ const projectConfig = await loadConfigFile(
171
+ resolve(cwd, ".pi", "television.json"),
172
+ );
73
173
 
74
- return args;
174
+ return normalizeTelevisionConfig({
175
+ ...globalConfig,
176
+ ...projectConfig,
177
+ });
75
178
  }
76
179
 
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;
180
+ export function rankTelevisionResults(
181
+ paths: string[],
182
+ query: string | undefined,
183
+ maxResults: number,
184
+ ): TelevisionSearchResult[] {
185
+ const trimmedQuery = query?.trim() ?? "";
186
+ const limitedMaxResults = Math.max(1, maxResults);
187
+
188
+ if (!trimmedQuery) {
189
+ return paths.slice(0, limitedMaxResults).map((path) => ({ path }));
190
+ }
191
+
192
+ const exactPrefixMatches = paths.filter((path) =>
193
+ path.startsWith(trimmedQuery),
194
+ );
195
+ const basenamePrefixMatches = paths.filter(
196
+ (path) =>
197
+ basename(path).startsWith(trimmedQuery) &&
198
+ !exactPrefixMatches.includes(path),
199
+ );
200
+ const fuzzyMatches = fuzzyFilter(paths, trimmedQuery, (path) => {
201
+ const name = basename(path);
202
+ return name === path ? path : `${name} ${path}`;
203
+ });
204
+
205
+ return uniq([
206
+ ...exactPrefixMatches,
207
+ ...basenamePrefixMatches,
208
+ ...fuzzyMatches,
209
+ ])
210
+ .slice(0, limitedMaxResults)
211
+ .map((path) => ({ path }));
212
+ }
213
+
214
+ export function createDefaultSearcher(pi: ExtensionAPI): TelevisionSearcher {
215
+ const cache = new Map<string, FileIndexCache>();
216
+
217
+ return async ({
218
+ cwd,
219
+ query,
220
+ signal,
221
+ maxResults = DEFAULT_MAX_RESULTS,
222
+ refreshMs = DEFAULT_REFRESH_MS,
223
+ includeFolders = DEFAULT_INCLUDE_FOLDERS,
224
+ }) => {
225
+ const now = Date.now();
226
+ const cached = cache.get(cwd);
227
+
228
+ if (cached?.entries && now - cached.loadedAt < refreshMs) {
229
+ return rankTelevisionResults(cached.entries, query, maxResults);
84
230
  }
85
231
 
86
- const child = spawn("tv", buildTvArgs(options.query), {
87
- cwd: options.cwd,
88
- stdio: ["inherit", "pipe", "inherit"],
89
- });
232
+ if (cached?.pending) {
233
+ const entries = await cached.pending;
234
+ return rankTelevisionResults(entries, query, maxResults);
235
+ }
90
236
 
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;
237
+ const fdArgs = includeFolders
238
+ ? ["--hidden", "--follow", "--exclude", ".git", "--strip-cwd-prefix"]
239
+ : [
240
+ "--type",
241
+ "f",
242
+ "--hidden",
243
+ "--follow",
244
+ "--exclude",
245
+ ".git",
246
+ "--strip-cwd-prefix",
247
+ ];
248
+
249
+ const pending = (async () => {
250
+ const result = await pi.exec("fd", fdArgs, {
251
+ cwd,
252
+ signal,
253
+ timeout: 10_000,
254
+ });
255
+
256
+ if (result.code !== 0) {
257
+ const details = result.stderr.trim() || `exit code ${result.code}`;
258
+ throw new Error(`television: fd failed: ${details}`);
124
259
  }
125
- if (code === 130 || code === 1) {
126
- settle({ status: "cancelled" });
127
- return;
260
+
261
+ const entries = cleanPaths(result.stdout);
262
+ cache.set(cwd, { loadedAt: Date.now(), entries });
263
+ return entries;
264
+ })();
265
+
266
+ cache.set(cwd, { loadedAt: now, pending });
267
+
268
+ try {
269
+ const entries = await pending;
270
+ return rankTelevisionResults(entries, query, maxResults);
271
+ } catch (error) {
272
+ cache.delete(cwd);
273
+ throw error;
274
+ }
275
+ };
276
+ }
277
+
278
+ export function createTelevisionAutocompleteProvider(
279
+ current: AutocompleteProvider,
280
+ searcher: TelevisionSearcher,
281
+ cwd: string,
282
+ config: TelevisionResolvedConfig,
283
+ ): AutocompleteProvider {
284
+ return {
285
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
286
+ const line = lines[cursorLine] ?? "";
287
+ const textBeforeCursor = line.slice(0, cursorCol);
288
+ const token = extractFileToken(textBeforeCursor);
289
+
290
+ if (token === undefined) {
291
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
128
292
  }
129
- settle({
130
- status: "failed",
131
- message: `television exited with code ${code ?? "unknown"}`,
293
+
294
+ const results = await searcher({
295
+ cwd,
296
+ query: token,
297
+ signal: options.signal,
298
+ maxResults: config.maxResults,
299
+ refreshMs: config.refreshMs,
300
+ includeFolders: config.includeFolders,
132
301
  });
133
- });
134
- });
302
+
303
+ if (options.signal.aborted || results.length === 0) {
304
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
305
+ }
306
+
307
+ return {
308
+ prefix: `@${token}`,
309
+ items: results.slice(0, config.maxResults).map(toAutocompleteItem),
310
+ };
311
+ },
312
+
313
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
314
+ return current.applyCompletion(
315
+ lines,
316
+ cursorLine,
317
+ cursorCol,
318
+ item,
319
+ prefix,
320
+ );
321
+ },
322
+
323
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
324
+ return (
325
+ current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
326
+ true
327
+ );
328
+ },
329
+ };
135
330
  }
136
331
 
137
332
  export function toEditorAttachmentPath(
@@ -161,17 +356,25 @@ function isBoundaryKey(data: string): boolean {
161
356
  );
162
357
  }
163
358
 
164
- async function pickFile(
359
+ async function findFiles(
165
360
  ctx: ExtensionContext,
166
- runner: TelevisionRunner,
361
+ searcher: TelevisionSearcher,
167
362
  query: string | undefined,
168
- ): Promise<TelevisionPickResult> {
169
- ctx.ui.setStatus(STATUS_KEY, "television: picking file");
170
- ctx.ui.setWorkingMessage("television is picking a file");
363
+ config: TelevisionResolvedConfig,
364
+ ): Promise<TelevisionSearchResult[]> {
365
+ ctx.ui.setStatus(STATUS_KEY, "television: finding files");
366
+ ctx.ui.setWorkingMessage("television is finding files");
171
367
  ctx.ui.setWorkingIndicator({ frames: ["tv"], intervalMs: 1000 });
172
368
 
173
369
  try {
174
- return await runner({ cwd: ctx.cwd, query, signal: ctx.signal });
370
+ return await searcher({
371
+ cwd: ctx.cwd,
372
+ query,
373
+ signal: ctx.signal,
374
+ maxResults: config.maxResults,
375
+ refreshMs: config.refreshMs,
376
+ includeFolders: config.includeFolders,
377
+ });
175
378
  } finally {
176
379
  ctx.ui.setStatus(STATUS_KEY, undefined);
177
380
  ctx.ui.setWorkingMessage();
@@ -179,26 +382,43 @@ async function pickFile(
179
382
  }
180
383
  }
181
384
 
182
- async function pastePickedFile(
385
+ async function pickFileWithSelectDialog(
183
386
  ctx: ExtensionContext,
184
- runner: TelevisionRunner,
387
+ searcher: TelevisionSearcher,
185
388
  query: string | undefined,
389
+ config: TelevisionResolvedConfig,
186
390
  ): Promise<TelevisionPickResult> {
187
- const result = await pickFile(ctx, runner, query);
391
+ try {
392
+ const results = await findFiles(ctx, searcher, query, config);
188
393
 
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
- }
394
+ if (results.length === 0) {
395
+ ctx.ui.notify("television: no matching files found", "warning");
396
+ return { status: "cancelled" };
397
+ }
398
+
399
+ const choice = await ctx.ui.select(
400
+ "television",
401
+ results.slice(0, config.maxResults).map((result) => result.path),
402
+ );
194
403
 
195
- return result;
404
+ if (!choice) {
405
+ return { status: "cancelled" };
406
+ }
407
+
408
+ ctx.ui.pasteToEditor(toEditorAttachmentPath(choice, ctx.cwd));
409
+ return { status: "selected", path: choice };
410
+ } catch (error) {
411
+ const message = error instanceof Error ? error.message : String(error);
412
+ ctx.ui.notify(message, "error");
413
+ return { status: "failed", message };
414
+ }
196
415
  }
197
416
 
198
417
  function installShortcut(
199
418
  ctx: ExtensionContext,
200
- runner: TelevisionRunner,
419
+ searcher: TelevisionSearcher,
201
420
  shortcut: string,
421
+ config: TelevisionResolvedConfig,
202
422
  ): void {
203
423
  let pickerOpen = false;
204
424
  let lastKeyWasBoundary = true;
@@ -218,7 +438,12 @@ function installShortcut(
218
438
  }
219
439
 
220
440
  pickerOpen = true;
221
- void pastePickedFile(ctx, runner, query).finally(() => {
441
+ void pickFileWithSelectDialog(
442
+ ctx,
443
+ searcher,
444
+ query || undefined,
445
+ config,
446
+ ).finally(() => {
222
447
  pickerOpen = false;
223
448
  lastKeyWasBoundary = true;
224
449
  });
@@ -232,20 +457,49 @@ function installShortcut(
232
457
  export function createExtension(options: TelevisionExtensionOptions = {}) {
233
458
  const commandName = options.commandName ?? DEFAULT_COMMAND_NAME;
234
459
  const shortcut = options.shortcut ?? DEFAULT_SHORTCUT;
235
- const runner = options.runner ?? runTelevision;
236
460
 
237
461
  return {
238
462
  name: extensionInfo.name,
239
463
  register(pi: ExtensionAPI): void {
240
- pi.on("session_start", (_event, ctx) => {
241
- installShortcut(ctx, runner, shortcut);
464
+ const searcher = options.searcher ?? createDefaultSearcher(pi);
465
+ const configLoader = options.configLoader ?? loadTelevisionConfig;
466
+ let config = defaultResolvedConfig;
467
+
468
+ pi.on("session_start", async (_event, ctx) => {
469
+ try {
470
+ config = await configLoader(ctx.cwd);
471
+ } catch (error) {
472
+ const message =
473
+ error instanceof Error ? error.message : String(error);
474
+ config = defaultResolvedConfig;
475
+ ctx.ui.notify(message, "error");
476
+ }
477
+
478
+ if (config.mode === "native-live") {
479
+ ctx.ui.addAutocompleteProvider((current) =>
480
+ createTelevisionAutocompleteProvider(
481
+ current,
482
+ searcher,
483
+ ctx.cwd,
484
+ config,
485
+ ),
486
+ );
487
+ return;
488
+ }
489
+
490
+ installShortcut(ctx, searcher, shortcut, config);
242
491
  });
243
492
 
244
493
  pi.registerCommand(commandName, {
245
494
  description:
246
- "Pick a file with television (tv) and insert it as an @file attachment",
495
+ "Find a file in the background and insert it as an @file attachment",
247
496
  handler: async (args, ctx) => {
248
- await pastePickedFile(ctx, runner, args.trim());
497
+ await pickFileWithSelectDialog(
498
+ ctx,
499
+ searcher,
500
+ args.trim() || undefined,
501
+ config,
502
+ );
249
503
  },
250
504
  });
251
505
  },
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