@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 +22 -0
- package/README.md +40 -0
- package/package.json +57 -0
- package/src/extension.ts +253 -0
- package/src/index.ts +18 -0
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
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -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
|
+
}
|