@liushihao456/pi-emacs 0.1.0 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +7 -0
  2. package/index.ts +695 -16
  3. package/package.json +6 -2
package/README.md CHANGED
@@ -6,6 +6,8 @@ Pi extension for opening `emacsclient` from Pi's TUI.
6
6
 
7
7
  - Starts an Emacs daemon automatically when Pi starts.
8
8
  - Adds `/emacs` command.
9
+ - Adds `/emacs:find-file` command with a file explorer for opening files or directories.
10
+ - Adds `/emacs:project-find-file` command with a fuzzy picker over non-ignored project files from ripgrep.
9
11
  - Adds `ctrl+g` shortcut to open `emacsclient -nw` in the terminal.
10
12
  - Remembers the last file touched by Pi `edit` / `write` tools and opens it on next launch.
11
13
  - Falls back to `dired` in the current working directory when no recent file exists.
@@ -30,12 +32,17 @@ pi install /path/to/pi-emacs
30
32
  - Emacs available as `emacs`
31
33
  - Emacs client available as `emacsclient`
32
34
  - Pi interactive TUI mode
35
+ - `@vscode/ripgrep` installed with this package for `/emacs:project-find-file`
33
36
 
34
37
  ## Usage
35
38
 
36
39
  - `/emacs` — open Emacs client
40
+ - `/emacs:find-file` — choose a file or directory and open it in Emacs
41
+ - `/emacs:project-find-file` — fuzzy-find a non-ignored project file and open it in Emacs
37
42
  - `ctrl+g` — open Emacs client
38
43
 
44
+ Pi extension shortcuts currently support single key events, so Emacs-style multi-key chords such as `C-x C-f` and `C-c p f` are documented here as commands instead of registered as shortcuts.
45
+
39
46
  ## Publish
40
47
 
41
48
  ```bash
package/index.ts CHANGED
@@ -2,8 +2,26 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
+ import { rgPath } from "@vscode/ripgrep";
6
+ import { readdirSync, statSync } from "node:fs";
5
7
  import { spawn } from "node:child_process";
6
- import { isAbsolute, resolve } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import path, { isAbsolute, resolve } from "node:path";
10
+ import {
11
+ fuzzyFilter,
12
+ getKeybindings,
13
+ Input,
14
+ Key,
15
+ matchesKey,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ type Component,
19
+ type Focusable,
20
+ } from "@earendil-works/pi-tui";
21
+
22
+ type Theme = {
23
+ fg(color: string, text: string): string;
24
+ };
7
25
 
8
26
  type EmacsState = {
9
27
  startedEmacsServer: boolean;
@@ -11,18 +29,30 @@ type EmacsState = {
11
29
  lastEditedFile?: string;
12
30
  };
13
31
 
14
- const state: EmacsState = ((globalThis as any).__piEmacsExtensionState ??= {
15
- startedEmacsServer: false,
16
- });
17
-
18
32
  type SpawnOptions = {
19
33
  cwd?: string;
20
- stdio?: "inherit" | "ignore";
34
+ stdio?: "inherit" | "ignore" | "pipe";
21
35
  timeoutMs?: number;
22
36
  onBefore?: () => void;
23
37
  onAfter?: () => void;
24
38
  };
25
39
 
40
+ type FileEntry = {
41
+ name: string;
42
+ path: string;
43
+ isDirectory: boolean;
44
+ mode: string;
45
+ size: string;
46
+ modified: Date;
47
+ };
48
+
49
+ const state: EmacsState = ((globalThis as any).__piEmacsExtensionState ??= {
50
+ startedEmacsServer: false,
51
+ });
52
+
53
+ const FILE_EXPLORER_MAX_VISIBLE = 8;
54
+ const PROJECT_PICKER_MAX_VISIBLE = 12;
55
+
26
56
  function run(command: string, args: string[], options: SpawnOptions = {}) {
27
57
  return new Promise<void>((resolve, reject) => {
28
58
  options.onBefore?.();
@@ -58,6 +88,34 @@ function run(command: string, args: string[], options: SpawnOptions = {}) {
58
88
  });
59
89
  }
60
90
 
91
+ function execText(command: string, args: string[], options: SpawnOptions = {}) {
92
+ return new Promise<string>((resolve, reject) => {
93
+ const child = spawn(command, args, {
94
+ cwd: options.cwd,
95
+ env: process.env,
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ });
98
+ const stdout: Buffer[] = [];
99
+ const stderr: Buffer[] = [];
100
+
101
+ child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
102
+ child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
103
+ child.on("error", reject);
104
+ child.on("close", (code, signal) => {
105
+ if (code === 0) {
106
+ resolve(Buffer.concat(stdout).toString("utf8"));
107
+ return;
108
+ }
109
+ reject(
110
+ new Error(
111
+ Buffer.concat(stderr).toString("utf8").trim() ||
112
+ `${command} exited with ${signal ?? code}`,
113
+ ),
114
+ );
115
+ });
116
+ });
117
+ }
118
+
61
119
  function emacsClient(args: string[], options: SpawnOptions = {}) {
62
120
  return run("emacsclient", args, options);
63
121
  }
@@ -66,10 +124,10 @@ function withTerminalMouse(expression: string) {
66
124
  return `(progn (xterm-mouse-mode 1) (mouse-wheel-mode 1) ${expression})`;
67
125
  }
68
126
 
69
- function findFileExpression(path: string) {
127
+ function findFileExpression(filePath: string) {
70
128
  return withTerminalMouse(
71
129
  [
72
- `(let* ((file ${JSON.stringify(path)})`,
130
+ `(let* ((file ${JSON.stringify(filePath)})`,
73
131
  "(buf (find-buffer-visiting file)))",
74
132
  "(if buf",
75
133
  "(progn",
@@ -84,7 +142,24 @@ function findFileExpression(path: string) {
84
142
  }
85
143
 
86
144
  function diredExpression(cwd: string) {
87
- return withTerminalMouse(`(dired ${JSON.stringify(cwd)})`);
145
+ return withTerminalMouse(
146
+ [
147
+ `(let* ((dir ${JSON.stringify(cwd)})`,
148
+ "(buf (dired-find-buffer-nocreate dir)))",
149
+ "(if buf",
150
+ "(progn",
151
+ "(with-current-buffer buf",
152
+ "(revert-buffer :ignore-auto :noconfirm))",
153
+ "(switch-to-buffer buf))",
154
+ "(dired dir)))",
155
+ ].join(" "),
156
+ );
157
+ }
158
+
159
+ function expressionForPath(targetPath: string) {
160
+ return statSync(targetPath).isDirectory()
161
+ ? diredExpression(targetPath)
162
+ : findFileExpression(targetPath);
88
163
  }
89
164
 
90
165
  function emacsClientArgs(cwd: string) {
@@ -94,9 +169,565 @@ function emacsClientArgs(cwd: string) {
94
169
  }
95
170
 
96
171
  function rememberEditedFile(input: unknown, cwd: string) {
97
- const path = (input as { path?: string }).path;
98
- if (!path) return;
99
- state.lastEditedFile = isAbsolute(path) ? path : resolve(cwd, path);
172
+ const filePath = (input as { path?: string }).path;
173
+ if (!filePath) return;
174
+ state.lastEditedFile = isAbsolute(filePath)
175
+ ? filePath
176
+ : resolve(cwd, filePath);
177
+ }
178
+
179
+ function fits(width: number, text: string): string {
180
+ return truncateToWidth(text, Math.max(0, width), "…");
181
+ }
182
+
183
+ function indent(width: number, text: string): string {
184
+ return fits(width, ` ${text}`);
185
+ }
186
+
187
+ function renderInputChild(input: Input, width: number): string {
188
+ const line = input.render(Math.max(1, width))[0] ?? "";
189
+ return line.startsWith("> ") ? line.slice(2) : line;
190
+ }
191
+
192
+ function setInputValueAtEnd(input: Input, value: string): void {
193
+ input.setValue(value);
194
+ (input as unknown as { cursor: number }).cursor = value.length;
195
+ }
196
+
197
+ function dirPrefix(value: string): string {
198
+ const slash = value.lastIndexOf("/");
199
+ return slash >= 0 ? value.slice(0, slash + 1) : "";
200
+ }
201
+
202
+ function formatSize(bytes: number): string {
203
+ if (bytes < 1000) return `${bytes}`;
204
+ if (bytes < 1_000_000)
205
+ return `${(bytes / 1000).toFixed(bytes < 10_000 ? 1 : 0)}k`;
206
+ if (bytes < 1_000_000_000)
207
+ return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)}M`;
208
+ return `${(bytes / 1_000_000_000).toFixed(1)}G`;
209
+ }
210
+
211
+ function modeString(mode: number, isDirectory: boolean): string {
212
+ const type = isDirectory ? "d" : "-";
213
+ const bits = [0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001]
214
+ .map((bit, index) => (mode & bit ? "rwx"[index % 3] : "-"))
215
+ .join("");
216
+ return `${type}${bits}`;
217
+ }
218
+
219
+ function relativeTime(date: Date): string {
220
+ const ms = Date.now() - date.getTime();
221
+ if (!Number.isFinite(ms) || ms < 0) return "now";
222
+ const sec = Math.floor(ms / 1000);
223
+ if (sec < 60) return "now";
224
+ const min = Math.floor(sec / 60);
225
+ if (min < 60) return `${min}m ago`;
226
+ const hour = Math.floor(min / 60);
227
+ if (hour < 24) return `${hour}h ago`;
228
+ const day = Math.floor(hour / 24);
229
+ if (day < 30) return `${day}d ago`;
230
+ const month = Math.floor(day / 30);
231
+ if (month < 12) return `${month}mo ago`;
232
+ return `${Math.floor(month / 12)}y ago`;
233
+ }
234
+
235
+ function normalizeExistingDir(input: string): string | null {
236
+ try {
237
+ const absolute = path.resolve(input.trim());
238
+ if (!statSync(absolute).isDirectory()) return null;
239
+ return absolute;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ function readFileEntries(dir: string): FileEntry[] {
246
+ const entries: FileEntry[] = [
247
+ {
248
+ name: "./",
249
+ path: dir,
250
+ isDirectory: true,
251
+ mode: "drwxr-xr-x",
252
+ size: "",
253
+ modified: new Date(),
254
+ },
255
+ ];
256
+
257
+ for (const dirent of readdirSync(dir, { withFileTypes: true })) {
258
+ try {
259
+ const entryPath = path.join(dir, dirent.name);
260
+ const stat = statSync(entryPath);
261
+ const isDirectory = stat.isDirectory();
262
+ entries.push({
263
+ name: `${dirent.name}${isDirectory ? "/" : ""}`,
264
+ path: entryPath,
265
+ isDirectory,
266
+ mode: modeString(stat.mode, isDirectory),
267
+ size: formatSize(stat.size),
268
+ modified: stat.mtime,
269
+ });
270
+ } catch {
271
+ // Ignore unreadable entries.
272
+ }
273
+ }
274
+
275
+ return entries.sort((a, b) => {
276
+ if (a.name === "./") return -1;
277
+ if (b.name === "./") return 1;
278
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
279
+ return a.name.localeCompare(b.name);
280
+ });
281
+ }
282
+
283
+ class FileExplorer implements Component, Focusable {
284
+ private entries: FileEntry[] = [];
285
+ private selectedIndex = 0;
286
+ private readonly searchInput = new Input();
287
+ private error: string | undefined;
288
+
289
+ constructor(
290
+ initialCwd: string,
291
+ private readonly theme: Theme,
292
+ private readonly done: (path: string | null) => void,
293
+ private readonly requestRender: () => void,
294
+ ) {
295
+ setInputValueAtEnd(
296
+ this.searchInput,
297
+ `${normalizeExistingDir(initialCwd) ?? homedir()}/`,
298
+ );
299
+ this.refresh();
300
+ }
301
+
302
+ render(width: number): string[] {
303
+ const lines: string[] = [];
304
+ lines.push(this.border(width));
305
+ lines.push(this.header(width));
306
+ lines.push(this.border(width, "dim"));
307
+ this.renderEntries(lines, width);
308
+ lines.push(this.border(width));
309
+ lines.push(
310
+ this.theme.fg(
311
+ "dim",
312
+ fits(
313
+ width,
314
+ "↑↓/<C-p>/<C-n> move · <tab> enter folder · <enter> open · <M-backspace> parent · <esc> cancel",
315
+ ),
316
+ ),
317
+ );
318
+ return lines;
319
+ }
320
+
321
+ get focused(): boolean {
322
+ return this.searchInput.focused;
323
+ }
324
+
325
+ set focused(value: boolean) {
326
+ this.searchInput.focused = value;
327
+ }
328
+
329
+ invalidate(): void {
330
+ this.searchInput.invalidate();
331
+ }
332
+
333
+ handleInput(data: string): void {
334
+ if (matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.escape)) {
335
+ this.done(null);
336
+ return;
337
+ }
338
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) {
339
+ this.move(-1);
340
+ return;
341
+ }
342
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) {
343
+ this.move(1);
344
+ return;
345
+ }
346
+ if (matchesKey(data, Key.tab)) {
347
+ this.enterSelectedDirectory();
348
+ return;
349
+ }
350
+ if (matchesKey(data, Key.enter)) {
351
+ this.chooseSelectedPath();
352
+ return;
353
+ }
354
+ if (getKeybindings().matches(data, "tui.editor.deleteWordBackward")) {
355
+ this.deletePathSegmentBackward();
356
+ return;
357
+ }
358
+
359
+ const before = this.search;
360
+ const beforeDir = dirPrefix(before);
361
+ this.searchInput.handleInput(data);
362
+ const after = this.search;
363
+ if (after !== before) {
364
+ if (dirPrefix(after) !== beforeDir) this.refresh();
365
+ else this.clampSelection();
366
+ }
367
+ this.requestRender();
368
+ }
369
+
370
+ private get search(): string {
371
+ return this.searchInput.getValue();
372
+ }
373
+
374
+ private set search(value: string) {
375
+ setInputValueAtEnd(this.searchInput, value);
376
+ }
377
+
378
+ private deletePathSegmentBackward(): void {
379
+ const before = this.search;
380
+ const trimmed = before.replace(/\/+$/, "");
381
+ const slash = trimmed.lastIndexOf("/");
382
+ if (slash < 0) return;
383
+ const next = trimmed.slice(0, slash + 1);
384
+ if (next === before) return;
385
+ this.search = next || "/";
386
+ this.refresh();
387
+ this.requestRender();
388
+ }
389
+
390
+ private refresh(): void {
391
+ try {
392
+ this.entries = readFileEntries(dirPrefix(this.search));
393
+ this.error = undefined;
394
+ this.selectedIndex = Math.min(1, Math.max(0, this.entries.length - 1));
395
+ } catch (error) {
396
+ this.entries = [];
397
+ this.selectedIndex = 0;
398
+ this.error = error instanceof Error ? error.message : String(error);
399
+ }
400
+ }
401
+
402
+ private header(width: number): string {
403
+ const entries = this.filteredEntries();
404
+ const total = Math.max(1, entries.length);
405
+ const index = Math.min(this.selectedIndex + 1, total);
406
+ const prefix = `${index}/${total}\tFind file: `;
407
+ const input = renderInputChild(
408
+ this.searchInput,
409
+ Math.max(1, width - visibleWidth(prefix)),
410
+ );
411
+ return this.theme.fg("accent", fits(width, `${prefix}${input}`));
412
+ }
413
+
414
+ private border(width: number, color: "accent" | "dim" = "accent"): string {
415
+ return this.theme.fg(color, "─".repeat(Math.max(0, width)));
416
+ }
417
+
418
+ private renderEntries(lines: string[], width: number): void {
419
+ if (this.error) {
420
+ lines.push(this.theme.fg("dim", indent(width, this.error)));
421
+ this.padRows(lines, width, 1);
422
+ return;
423
+ }
424
+ const entries = this.filteredEntries();
425
+ if (entries.length === 0) {
426
+ lines.push(
427
+ this.theme.fg(
428
+ "dim",
429
+ indent(width, this.search ? "No matches." : "No entries."),
430
+ ),
431
+ );
432
+ this.padRows(lines, width, 1);
433
+ return;
434
+ }
435
+
436
+ let rendered = 0;
437
+ const start = this.visibleStart(entries.length);
438
+ const end = Math.min(entries.length, start + FILE_EXPLORER_MAX_VISIBLE);
439
+ for (let i = start; i < end; i++) {
440
+ lines.push(
441
+ this.entryLine(width, entries[i]!, {
442
+ selected: i === this.selectedIndex,
443
+ }),
444
+ );
445
+ rendered++;
446
+ }
447
+ this.padRows(lines, width, rendered);
448
+ }
449
+
450
+ private entryLine(
451
+ width: number,
452
+ entry: FileEntry,
453
+ options: { selected: boolean },
454
+ ): string {
455
+ if (entry.name === "./") return this.currentDirLine(width, options);
456
+ const left = `${options.selected ? "›" : " "} ${entry.name}`;
457
+ const meta = `${entry.mode} ${entry.size.padStart(5)} ${relativeTime(entry.modified)}`;
458
+ const metaWidth = Math.min(38, Math.max(0, Math.floor(width * 0.48)));
459
+ const renderedMeta = fits(metaWidth, meta);
460
+ const renderedLeft = fits(
461
+ Math.max(0, width - visibleWidth(renderedMeta) - 1),
462
+ left,
463
+ );
464
+ const gap = " ".repeat(
465
+ Math.max(
466
+ 1,
467
+ width - visibleWidth(renderedLeft) - visibleWidth(renderedMeta),
468
+ ),
469
+ );
470
+ const styledLeft = options.selected
471
+ ? this.theme.fg("accent", renderedLeft)
472
+ : renderedLeft;
473
+ return `${styledLeft}${gap}${this.theme.fg("dim", renderedMeta)}`;
474
+ }
475
+
476
+ private currentDirLine(
477
+ width: number,
478
+ options: { selected: boolean },
479
+ ): string {
480
+ const marker = options.selected ? "›" : " ";
481
+ const name = `${marker} ./`;
482
+ const note = " (open current dir)";
483
+ const availableNoteWidth = Math.max(0, width - visibleWidth(name));
484
+ const renderedNote = fits(availableNoteWidth, note);
485
+ const renderedName = fits(
486
+ Math.max(0, width - visibleWidth(renderedNote)),
487
+ name,
488
+ );
489
+ const padding = " ".repeat(
490
+ Math.max(
491
+ 0,
492
+ width - visibleWidth(renderedName) - visibleWidth(renderedNote),
493
+ ),
494
+ );
495
+ const styledName = options.selected
496
+ ? this.theme.fg("accent", renderedName)
497
+ : renderedName;
498
+ return `${styledName}${this.theme.fg("dim", renderedNote)}${padding}`;
499
+ }
500
+
501
+ private visibleStart(total: number): number {
502
+ if (total <= FILE_EXPLORER_MAX_VISIBLE) return 0;
503
+ const half = Math.floor(FILE_EXPLORER_MAX_VISIBLE / 2);
504
+ return Math.min(
505
+ Math.max(0, this.selectedIndex - half),
506
+ total - FILE_EXPLORER_MAX_VISIBLE,
507
+ );
508
+ }
509
+
510
+ private padRows(lines: string[], width: number, rendered: number): void {
511
+ for (let i = rendered; i < FILE_EXPLORER_MAX_VISIBLE; i++) {
512
+ lines.push(" ".repeat(Math.max(0, width)));
513
+ }
514
+ }
515
+
516
+ private filteredEntries(): FileEntry[] {
517
+ const query = this.search.trim().split("/").pop() ?? "";
518
+ if (!query) return this.entries;
519
+ return fuzzyFilter(this.entries, query, (entry) => entry.name);
520
+ }
521
+
522
+ private clampSelection(): void {
523
+ const maxIndex = Math.max(0, this.filteredEntries().length - 1);
524
+ this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, maxIndex));
525
+ }
526
+
527
+ private move(delta: number): void {
528
+ const entries = this.filteredEntries();
529
+ if (entries.length === 0) return;
530
+ this.selectedIndex =
531
+ (this.selectedIndex + delta + entries.length) % entries.length;
532
+ this.requestRender();
533
+ }
534
+
535
+ private selected(): FileEntry | undefined {
536
+ return this.filteredEntries()[this.selectedIndex];
537
+ }
538
+
539
+ private enterSelectedDirectory(): void {
540
+ const entry = this.selected();
541
+ if (!entry?.isDirectory) return;
542
+ const next = `${normalizeExistingDir(entry.path)}/`;
543
+ if (!next) return;
544
+ this.search = next;
545
+ this.refresh();
546
+ this.requestRender();
547
+ }
548
+
549
+ private chooseSelectedPath(): void {
550
+ const entry = this.selected();
551
+ if (entry) this.done(entry.path);
552
+ }
553
+ }
554
+
555
+ class ProjectFilePicker implements Component, Focusable {
556
+ private selectedIndex = 0;
557
+ private readonly searchInput = new Input();
558
+
559
+ constructor(
560
+ private readonly cwd: string,
561
+ private readonly files: string[],
562
+ private readonly theme: Theme,
563
+ private readonly done: (path: string | null) => void,
564
+ private readonly requestRender: () => void,
565
+ ) {}
566
+
567
+ render(width: number): string[] {
568
+ const lines: string[] = [];
569
+ const filtered = this.filteredFiles();
570
+ lines.push(this.border(width));
571
+ lines.push(this.header(width, filtered.length));
572
+ lines.push(this.border(width, "dim"));
573
+ this.renderFiles(lines, width, filtered);
574
+ lines.push(this.border(width));
575
+ lines.push(
576
+ this.theme.fg(
577
+ "dim",
578
+ fits(width, "↑↓/<C-p>/<C-n> move · <enter> open · <esc> cancel"),
579
+ ),
580
+ );
581
+ return lines;
582
+ }
583
+
584
+ get focused(): boolean {
585
+ return this.searchInput.focused;
586
+ }
587
+
588
+ set focused(value: boolean) {
589
+ this.searchInput.focused = value;
590
+ }
591
+
592
+ invalidate(): void {
593
+ this.searchInput.invalidate();
594
+ }
595
+
596
+ handleInput(data: string): void {
597
+ if (matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.escape)) {
598
+ this.done(null);
599
+ return;
600
+ }
601
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) {
602
+ this.move(-1);
603
+ return;
604
+ }
605
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) {
606
+ this.move(1);
607
+ return;
608
+ }
609
+ if (matchesKey(data, Key.enter)) {
610
+ this.chooseSelectedFile();
611
+ return;
612
+ }
613
+
614
+ const before = this.searchInput.getValue();
615
+ this.searchInput.handleInput(data);
616
+ if (this.searchInput.getValue() !== before) this.clampSelection();
617
+ this.requestRender();
618
+ }
619
+
620
+ private header(width: number, count: number): string {
621
+ const total = Math.max(1, count);
622
+ const index = Math.min(this.selectedIndex + 1, total);
623
+ const prefix = `${index}/${total}\tProject file: `;
624
+ const input = renderInputChild(
625
+ this.searchInput,
626
+ Math.max(1, width - visibleWidth(prefix)),
627
+ );
628
+ return this.theme.fg("accent", fits(width, `${prefix}${input}`));
629
+ }
630
+
631
+ private border(width: number, color: "accent" | "dim" = "accent"): string {
632
+ return this.theme.fg(color, "─".repeat(Math.max(0, width)));
633
+ }
634
+
635
+ private renderFiles(lines: string[], width: number, files: string[]): void {
636
+ if (files.length === 0) {
637
+ lines.push(this.theme.fg("dim", indent(width, "No matches.")));
638
+ this.padRows(lines, width, 1);
639
+ return;
640
+ }
641
+
642
+ let rendered = 0;
643
+ const start = this.visibleStart(files.length);
644
+ const end = Math.min(files.length, start + PROJECT_PICKER_MAX_VISIBLE);
645
+ for (let i = start; i < end; i++) {
646
+ const marker = i === this.selectedIndex ? "›" : " ";
647
+ const line = fits(width, `${marker} ${files[i]}`);
648
+ lines.push(
649
+ i === this.selectedIndex ? this.theme.fg("accent", line) : line,
650
+ );
651
+ rendered++;
652
+ }
653
+ this.padRows(lines, width, rendered);
654
+ }
655
+
656
+ private padRows(lines: string[], width: number, rendered: number): void {
657
+ for (let i = rendered; i < PROJECT_PICKER_MAX_VISIBLE; i++) {
658
+ lines.push(" ".repeat(Math.max(0, width)));
659
+ }
660
+ }
661
+
662
+ private filteredFiles(): string[] {
663
+ const query = this.searchInput.getValue().trim();
664
+ return query ? fuzzyFilter(this.files, query, (file) => file) : this.files;
665
+ }
666
+
667
+ private visibleStart(total: number): number {
668
+ if (total <= PROJECT_PICKER_MAX_VISIBLE) return 0;
669
+ const half = Math.floor(PROJECT_PICKER_MAX_VISIBLE / 2);
670
+ return Math.min(
671
+ Math.max(0, this.selectedIndex - half),
672
+ total - PROJECT_PICKER_MAX_VISIBLE,
673
+ );
674
+ }
675
+
676
+ private clampSelection(): void {
677
+ const maxIndex = Math.max(0, this.filteredFiles().length - 1);
678
+ this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, maxIndex));
679
+ }
680
+
681
+ private move(delta: number): void {
682
+ const files = this.filteredFiles();
683
+ if (files.length === 0) return;
684
+ this.selectedIndex =
685
+ (this.selectedIndex + delta + files.length) % files.length;
686
+ this.requestRender();
687
+ }
688
+
689
+ private chooseSelectedFile(): void {
690
+ const file = this.filteredFiles()[this.selectedIndex];
691
+ if (file) this.done(path.join(this.cwd, file));
692
+ }
693
+ }
694
+
695
+ async function projectFiles(cwd: string): Promise<string[]> {
696
+ const output = await execText(
697
+ rgPath,
698
+ ["--files", "--hidden", "--glob", "!.git/**"],
699
+ { cwd },
700
+ );
701
+ return output
702
+ .split(/\r?\n/)
703
+ .map((file) => file.trim())
704
+ .filter(Boolean)
705
+ .sort((a, b) => a.localeCompare(b));
706
+ }
707
+
708
+ async function choosePath(ctx: ExtensionContext): Promise<string | null> {
709
+ if (!ctx.hasUI) return null;
710
+ return (
711
+ (await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
712
+ return new FileExplorer(ctx.cwd, theme as Theme, done, () =>
713
+ tui.requestRender(),
714
+ );
715
+ })) ?? null
716
+ );
717
+ }
718
+
719
+ async function chooseProjectFile(
720
+ ctx: ExtensionContext,
721
+ ): Promise<string | null> {
722
+ if (!ctx.hasUI) return null;
723
+ const files = await projectFiles(ctx.cwd);
724
+ return (
725
+ (await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
726
+ return new ProjectFilePicker(ctx.cwd, files, theme as Theme, done, () =>
727
+ tui.requestRender(),
728
+ );
729
+ })) ?? null
730
+ );
100
731
  }
101
732
 
102
733
  async function ensureEmacsServer() {
@@ -131,14 +762,18 @@ async function stopEmacsServer() {
131
762
  state.serverStartPromise = undefined;
132
763
  }
133
764
 
134
- async function openEmacsClient(ctx: ExtensionContext) {
765
+ async function openEmacsWithArgs(
766
+ ctx: ExtensionContext,
767
+ args: string[],
768
+ errorPrefix = "Failed to start emacsclient",
769
+ ) {
135
770
  if (!ctx.hasUI) {
136
771
  ctx.ui.notify("emacsclient requires TUI mode", "error");
137
772
  return;
138
773
  }
139
774
 
140
775
  await ctx.ui.custom((tui, _theme, _keybindings, done) => {
141
- emacsClient(emacsClientArgs(ctx.cwd), {
776
+ emacsClient(args, {
142
777
  cwd: ctx.cwd,
143
778
  stdio: "inherit",
144
779
  onBefore: () => {
@@ -153,7 +788,7 @@ async function openEmacsClient(ctx: ExtensionContext) {
153
788
  })
154
789
  .then(() => done(null))
155
790
  .catch((error) => {
156
- ctx.ui.notify(`Failed to start emacsclient: ${error.message}`, "error");
791
+ ctx.ui.notify(`${errorPrefix}: ${error.message}`, "error");
157
792
  done(null);
158
793
  });
159
794
 
@@ -161,6 +796,40 @@ async function openEmacsClient(ctx: ExtensionContext) {
161
796
  });
162
797
  }
163
798
 
799
+ async function openEmacsClient(ctx: ExtensionContext) {
800
+ await openEmacsWithArgs(ctx, emacsClientArgs(ctx.cwd));
801
+ }
802
+
803
+ async function openEmacsPath(ctx: ExtensionContext, targetPath: string) {
804
+ state.lastEditedFile = statSync(targetPath).isDirectory()
805
+ ? state.lastEditedFile
806
+ : targetPath;
807
+ await openEmacsWithArgs(ctx, [
808
+ "-nw",
809
+ "-a",
810
+ "",
811
+ "-e",
812
+ expressionForPath(targetPath),
813
+ ]);
814
+ }
815
+
816
+ async function runFindFile(ctx: ExtensionContext) {
817
+ const targetPath = await choosePath(ctx);
818
+ if (targetPath) await openEmacsPath(ctx, targetPath);
819
+ }
820
+
821
+ async function runProjectFindFile(ctx: ExtensionContext) {
822
+ try {
823
+ const targetPath = await chooseProjectFile(ctx);
824
+ if (targetPath) await openEmacsPath(ctx, targetPath);
825
+ } catch (error) {
826
+ ctx.ui.notify(
827
+ `Failed to list project files: ${error instanceof Error ? error.message : String(error)}`,
828
+ "error",
829
+ );
830
+ }
831
+ }
832
+
164
833
  export default function (pi: ExtensionAPI) {
165
834
  pi.on("session_start", () => {
166
835
  ensureEmacsServer().catch((error) => {
@@ -183,10 +852,20 @@ export default function (pi: ExtensionAPI) {
183
852
  handler: async (_args, ctx) => openEmacsClient(ctx),
184
853
  });
185
854
 
855
+ pi.registerCommand("emacs:find-file", {
856
+ description: "Find and open a file or directory in Emacs",
857
+ handler: async (_args, ctx) => runFindFile(ctx),
858
+ });
859
+
860
+ pi.registerCommand("emacs:project-find-file", {
861
+ description: "Fuzzy-find a project file and open it in Emacs",
862
+ handler: async (_args, ctx) => runProjectFindFile(ctx),
863
+ });
864
+
186
865
  pi.registerShortcut("ctrl+g", {
187
866
  description: "Open emacsclient",
188
867
  handler: openEmacsClient,
189
868
  });
190
869
  }
191
870
 
192
- export { ensureEmacsServer, openEmacsClient, stopEmacsServer };
871
+ export { ensureEmacsServer, openEmacsClient, openEmacsPath, stopEmacsServer };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@liushihao456/pi-emacs",
3
- "version": "0.1.0",
4
- "description": "Pi extension that opens emacsclient in a popup terminal and tracks recently edited files.",
3
+ "version": "0.1.2",
4
+ "description": "Pi extension that allows switching to emacs seamlessly in a popup terminal.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
@@ -30,5 +30,9 @@
30
30
  },
31
31
  "engines": {
32
32
  "node": ">=20"
33
+ },
34
+ "dependencies": {
35
+ "@earendil-works/pi-tui": "^0.79.2",
36
+ "@vscode/ripgrep": "^1.18.0"
33
37
  }
34
38
  }