@ramarivera/pi-television 0.0.2

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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ramiro Rivera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @ramarivera/pi-television
2
+
3
+ Pi extension that replaces the fuzzy file finder with television (tv) for faster, non-blocking file search
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:@ramarivera/pi-television@0.0.1
9
+ ```
10
+
11
+ ## Local Development
12
+
13
+ This checkout is live-enabled for Pi through:
14
+
15
+ ```text
16
+ .pi/extensions/television/index.ts
17
+ ```
18
+
19
+ That shim imports the package entrypoint in `src/index.ts`, which imports the extension factory from `src/extension.ts`. Tests use the same symbol so local behavior, package behavior, and manual Pi behavior do not drift.
20
+
21
+ ```sh
22
+ npm install
23
+ npm run check
24
+ npm test
25
+ npm run test:e2e
26
+ npm pack --dry-run
27
+ ```
28
+
29
+ ## Publishing
30
+
31
+ Publishing uses GitHub Actions trusted publishing in `.github/workflows/publish.yml`.
32
+
33
+ Before the first publish, configure npm trusted publishing:
34
+
35
+ - owner/repo: `ramarivera/pi-television`
36
+ - workflow: `.github/workflows/publish.yml`
37
+ - environment: blank unless the workflow is changed to require one
38
+
39
+ No `NPM_TOKEN` is required for trusted publishing.
40
+
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
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",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Ramiro Rivera",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ramarivera/pi-television.git"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "main": "./src/index.ts",
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "check": "biome check . && tsc --noEmit",
26
+ "format": "biome format --write .",
27
+ "lint": "biome lint .",
28
+ "test": "tsx --test test/**/*.test.ts",
29
+ "test:e2e": "tsx --test e2e/test/**/*.test.ts",
30
+ "pack:dry-run": "npm pack --dry-run"
31
+ },
32
+ "keywords": [
33
+ "pi",
34
+ "pi-package",
35
+ "pi-extension",
36
+ "coding-agent"
37
+ ],
38
+ "pi": {
39
+ "extensions": [
40
+ "./src/index.ts"
41
+ ]
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-ai": "*",
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "@earendil-works/pi-tui": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@biomejs/biome": "^2.4.14",
50
+ "@earendil-works/pi-ai": "*",
51
+ "@earendil-works/pi-coding-agent": "*",
52
+ "@types/node": "^24.0.0",
53
+ "tsx": "^4.21.0",
54
+ "typescript": "^5.9.0",
55
+ "@earendil-works/pi-tui": "*"
56
+ }
57
+ }
@@ -0,0 +1,253 @@
1
+ import { spawn } from "node:child_process";
2
+ import { relative, resolve } from "node:path";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ TerminalInputHandler,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { decodeKittyPrintable, Key, matchesKey } from "@earendil-works/pi-tui";
9
+
10
+ export type ExtensionInfo = {
11
+ name: string;
12
+ description: string;
13
+ };
14
+
15
+ export type TelevisionPickResult =
16
+ | { status: "selected"; path: string }
17
+ | { status: "cancelled" }
18
+ | { status: "failed"; message: string };
19
+
20
+ export type TelevisionRunnerOptions = {
21
+ cwd: string;
22
+ query?: string;
23
+ signal?: AbortSignal;
24
+ };
25
+
26
+ export type TelevisionRunner = (
27
+ options: TelevisionRunnerOptions,
28
+ ) => Promise<TelevisionPickResult>;
29
+
30
+ export type TelevisionExtensionOptions = {
31
+ commandName?: string;
32
+ shortcut?: string;
33
+ runner?: TelevisionRunner;
34
+ };
35
+
36
+ const STATUS_KEY = "pi-television";
37
+ const DEFAULT_COMMAND_NAME = "television";
38
+ const DEFAULT_SHORTCUT = "@";
39
+
40
+ export const extensionInfo: ExtensionInfo = {
41
+ name: "television",
42
+ description:
43
+ "Pi extension that replaces the fuzzy file finder with television (tv) for faster, non-blocking file search",
44
+ };
45
+
46
+ function cleanSelectedPath(stdout: string): string | null {
47
+ const selected = stdout
48
+ .split(/\r?\n/)
49
+ .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());
72
+ }
73
+
74
+ return args;
75
+ }
76
+
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;
84
+ }
85
+
86
+ const child = spawn("tv", buildTvArgs(options.query), {
87
+ cwd: options.cwd,
88
+ stdio: ["inherit", "pipe", "inherit"],
89
+ });
90
+
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;
124
+ }
125
+ if (code === 130 || code === 1) {
126
+ settle({ status: "cancelled" });
127
+ return;
128
+ }
129
+ settle({
130
+ status: "failed",
131
+ message: `television exited with code ${code ?? "unknown"}`,
132
+ });
133
+ });
134
+ });
135
+ }
136
+
137
+ export function toEditorAttachmentPath(
138
+ selectedPath: string,
139
+ cwd: string,
140
+ ): string {
141
+ const absolute = resolve(cwd, selectedPath);
142
+ const relativePath = relative(cwd, absolute) || ".";
143
+ const displayPath = relativePath.startsWith("..") ? absolute : relativePath;
144
+ const normalized = displayPath.split("\\").join("/");
145
+ if (/[\s"'$`\\]/.test(normalized)) {
146
+ return `@"${normalized.replace(/(["\\$`])/g, "\\$1")}" `;
147
+ }
148
+ return `@${normalized} `;
149
+ }
150
+
151
+ function shortcutQuery(data: string, shortcut: string): string | null {
152
+ if (data.startsWith(shortcut)) return data.slice(shortcut.length);
153
+ return decodeKittyPrintable(data) === shortcut ? "" : null;
154
+ }
155
+
156
+ function isBoundaryKey(data: string): boolean {
157
+ return (
158
+ matchesKey(data, Key.space) ||
159
+ matchesKey(data, Key.enter) ||
160
+ matchesKey(data, Key.tab)
161
+ );
162
+ }
163
+
164
+ async function pickFile(
165
+ ctx: ExtensionContext,
166
+ runner: TelevisionRunner,
167
+ query: string | undefined,
168
+ ): Promise<TelevisionPickResult> {
169
+ ctx.ui.setStatus(STATUS_KEY, "television: picking file");
170
+ ctx.ui.setWorkingMessage("television is picking a file");
171
+ ctx.ui.setWorkingIndicator({ frames: ["tv"], intervalMs: 1000 });
172
+
173
+ try {
174
+ return await runner({ cwd: ctx.cwd, query, signal: ctx.signal });
175
+ } finally {
176
+ ctx.ui.setStatus(STATUS_KEY, undefined);
177
+ ctx.ui.setWorkingMessage();
178
+ ctx.ui.setWorkingIndicator();
179
+ }
180
+ }
181
+
182
+ async function pastePickedFile(
183
+ ctx: ExtensionContext,
184
+ runner: TelevisionRunner,
185
+ query: string | undefined,
186
+ ): Promise<TelevisionPickResult> {
187
+ const result = await pickFile(ctx, runner, query);
188
+
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
+ }
194
+
195
+ return result;
196
+ }
197
+
198
+ function installShortcut(
199
+ ctx: ExtensionContext,
200
+ runner: TelevisionRunner,
201
+ shortcut: string,
202
+ ): void {
203
+ let pickerOpen = false;
204
+ let lastKeyWasBoundary = true;
205
+
206
+ const handler: TerminalInputHandler = (data) => {
207
+ if (pickerOpen) return undefined;
208
+
209
+ const query = shortcutQuery(data, shortcut);
210
+ if (query === null) {
211
+ lastKeyWasBoundary = isBoundaryKey(data);
212
+ return undefined;
213
+ }
214
+
215
+ if (!lastKeyWasBoundary) {
216
+ lastKeyWasBoundary = isBoundaryKey(data);
217
+ return undefined;
218
+ }
219
+
220
+ pickerOpen = true;
221
+ void pastePickedFile(ctx, runner, query).finally(() => {
222
+ pickerOpen = false;
223
+ lastKeyWasBoundary = true;
224
+ });
225
+
226
+ return { consume: true };
227
+ };
228
+
229
+ ctx.ui.onTerminalInput?.(handler);
230
+ }
231
+
232
+ export function createExtension(options: TelevisionExtensionOptions = {}) {
233
+ const commandName = options.commandName ?? DEFAULT_COMMAND_NAME;
234
+ const shortcut = options.shortcut ?? DEFAULT_SHORTCUT;
235
+ const runner = options.runner ?? runTelevision;
236
+
237
+ return {
238
+ name: extensionInfo.name,
239
+ register(pi: ExtensionAPI): void {
240
+ pi.on("session_start", (_event, ctx) => {
241
+ installShortcut(ctx, runner, shortcut);
242
+ });
243
+
244
+ pi.registerCommand(commandName, {
245
+ description:
246
+ "Pick a file with television (tv) and insert it as an @file attachment",
247
+ handler: async (args, ctx) => {
248
+ await pastePickedFile(ctx, runner, args.trim());
249
+ },
250
+ });
251
+ },
252
+ };
253
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createExtension } from "./extension.ts";
3
+
4
+ export type {
5
+ TelevisionExtensionOptions,
6
+ TelevisionPickResult,
7
+ TelevisionRunner,
8
+ TelevisionRunnerOptions,
9
+ } from "./extension.ts";
10
+ export {
11
+ createExtension,
12
+ extensionInfo,
13
+ toEditorAttachmentPath,
14
+ } from "./extension.ts";
15
+
16
+ export default function televisionExtension(pi: ExtensionAPI): void {
17
+ createExtension().register(pi);
18
+ }