@k3_2o/pi-move 0.1.0
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/.prettierrc.json +8 -0
- package/Makefile +53 -0
- package/README.md +41 -0
- package/eslint.config.js +30 -0
- package/index.ts +43 -0
- package/move.ts +144 -0
- package/overlay.ts +216 -0
- package/package.json +33 -0
- package/tsconfig.json +33 -0
- package/utils.ts +264 -0
package/.prettierrc.json
ADDED
package/Makefile
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
.PHONY: fmt fmt-check lint typecheck check security smoke install clean
|
|
2
|
+
|
|
3
|
+
# --- Formatting ---
|
|
4
|
+
|
|
5
|
+
fmt:
|
|
6
|
+
@echo "=== Formatting with Prettier ==="
|
|
7
|
+
npx prettier --write '*.ts'
|
|
8
|
+
|
|
9
|
+
fmt-check:
|
|
10
|
+
@echo "=== Checking format with Prettier ==="
|
|
11
|
+
npx prettier --check '*.ts'
|
|
12
|
+
|
|
13
|
+
# --- Linting ---
|
|
14
|
+
|
|
15
|
+
lint:
|
|
16
|
+
@echo "=== Linting with ESLint ==="
|
|
17
|
+
npx eslint '*.ts'
|
|
18
|
+
|
|
19
|
+
# --- Type Checking ---
|
|
20
|
+
|
|
21
|
+
typecheck:
|
|
22
|
+
@echo "=== Type checking with tsc ==="
|
|
23
|
+
npx tsc --noEmit
|
|
24
|
+
|
|
25
|
+
# --- Combined Check ---
|
|
26
|
+
|
|
27
|
+
check: fmt-check lint typecheck
|
|
28
|
+
@echo "=== All checks passed ==="
|
|
29
|
+
|
|
30
|
+
# --- Security Audit ---
|
|
31
|
+
|
|
32
|
+
security:
|
|
33
|
+
@echo "=== Security audit ==="
|
|
34
|
+
npm audit --audit-level=high
|
|
35
|
+
|
|
36
|
+
# --- Smoke Test ---
|
|
37
|
+
|
|
38
|
+
smoke:
|
|
39
|
+
@echo "=== Smoke test ==="
|
|
40
|
+
bun -e "import('./src/index.ts').then(() => console.log('SMOKE OK')).catch(e => { console.error(e); process.exit(1); })"
|
|
41
|
+
|
|
42
|
+
# --- Install ---
|
|
43
|
+
|
|
44
|
+
install:
|
|
45
|
+
@echo "=== Installing dependencies ==="
|
|
46
|
+
npm install
|
|
47
|
+
|
|
48
|
+
# --- Clean ---
|
|
49
|
+
|
|
50
|
+
clean:
|
|
51
|
+
@echo "=== Cleaning ==="
|
|
52
|
+
rm -rf node_modules/ dist/
|
|
53
|
+
rm -f package-lock.json
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# pi-move
|
|
2
|
+
|
|
3
|
+
A Pi extension that adds `/move` — switch to a fresh Pi session in any directory
|
|
4
|
+
from inside Pi. No quitting.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
Pi works in one directory at a time. To switch projects you normally have to
|
|
9
|
+
quit, `cd`, and restart. `/move` does that in one step.
|
|
10
|
+
|
|
11
|
+
## How
|
|
12
|
+
|
|
13
|
+
Type `/move`, an overlay pops up with a path input. Start typing, tab to autocomplete directories
|
|
14
|
+
as you go. Press Enter and Pi creates a new empty session in that
|
|
15
|
+
directory and switches to it.
|
|
16
|
+
|
|
17
|
+
If the directory doesn't exist, Pi asks if you want to create it.
|
|
18
|
+
no need to exit to make a new directory for your project
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- Pi 0.79+
|
|
23
|
+
- `fd` (pi usually demands you have this on start-up, as a pi user you probably already have it)
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi install @k3_2o/pi-move
|
|
29
|
+
# or
|
|
30
|
+
pi install git:github.com/k3-2o/pi-move
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or clone manually:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/k3-2o/pi-move ~/.pi/agent/extensions/pi-move
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import tseslint from "typescript-eslint";
|
|
3
|
+
import eslint from "@eslint/js";
|
|
4
|
+
|
|
5
|
+
export default tseslint.config(
|
|
6
|
+
eslint.configs.recommended,
|
|
7
|
+
...tseslint.configs.recommendedTypeChecked,
|
|
8
|
+
{
|
|
9
|
+
languageOptions: {
|
|
10
|
+
parserOptions: {
|
|
11
|
+
projectService: true,
|
|
12
|
+
tsconfigRootDir: import.meta.dirname,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
rules: {
|
|
18
|
+
"@typescript-eslint/no-unused-vars": [
|
|
19
|
+
"error",
|
|
20
|
+
{ argsIgnorePattern: "^_" },
|
|
21
|
+
],
|
|
22
|
+
"@typescript-eslint/no-non-null-assertion": "warn",
|
|
23
|
+
"@typescript-eslint/no-confusing-void-expression": "off",
|
|
24
|
+
"no-console": ["warn", { allow: ["warn", "error"] }],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
ignores: ["dist/", "node_modules/", "tests/", "scripts/"],
|
|
29
|
+
},
|
|
30
|
+
);
|
package/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// --- pi-move: Directory Switcher for Pi ---
|
|
2
|
+
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { handleMoveCommand } from "./move.js";
|
|
6
|
+
|
|
7
|
+
export default function piMoveExtension(pi: ExtensionAPI): void {
|
|
8
|
+
// --- Lifecycle: session_shutdown ---
|
|
9
|
+
// When leaving an empty session (user moved here via /move but sent
|
|
10
|
+
// no messages), delete the file so it doesn't clutter /resume.
|
|
11
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
12
|
+
const entries = ctx.sessionManager.getEntries();
|
|
13
|
+
// Check for actual user/assistant messages, not just metadata entries
|
|
14
|
+
// like model_change and thinking_level_change that pi auto-appends.
|
|
15
|
+
const hasRealMessages = entries.some(
|
|
16
|
+
(e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),
|
|
17
|
+
);
|
|
18
|
+
if (hasRealMessages) return; // Has real messages — keep it
|
|
19
|
+
|
|
20
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
21
|
+
if (!sessionFile) return;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
fs.unlinkSync(sessionFile);
|
|
25
|
+
} catch {
|
|
26
|
+
// File already gone — skip
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// --- Command: /move ---
|
|
31
|
+
|
|
32
|
+
pi.registerCommand("move", {
|
|
33
|
+
description:
|
|
34
|
+
"Move to a different directory — starts a fresh Pi session in the target directory",
|
|
35
|
+
getArgumentCompletions: (_argumentPrefix: string) => {
|
|
36
|
+
// We don't use inline arg completion — the overlay handles it
|
|
37
|
+
return null;
|
|
38
|
+
},
|
|
39
|
+
handler: async (args, ctx) => {
|
|
40
|
+
await handleMoveCommand(args, ctx);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
package/move.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { MoveOverlay, type MoveOverlayResult } from "./overlay.js";
|
|
7
|
+
import { resolveDirectory } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Handle the /move command.
|
|
11
|
+
*/
|
|
12
|
+
export async function handleMoveCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
13
|
+
const trimmedArg = args.trim();
|
|
14
|
+
|
|
15
|
+
if (trimmedArg.length > 0) {
|
|
16
|
+
const target = await resolveOrCreateDirectory(trimmedArg, ctx);
|
|
17
|
+
if (target !== null) {
|
|
18
|
+
await switchToNewSession(target, ctx);
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
24
|
+
ctx.ui.notify("/move requires interactive TUI mode", "error");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = await ctx.ui.custom<MoveOverlayResult | undefined>(
|
|
29
|
+
(_tui, theme, _keybindings, done) => new MoveOverlay(theme, ctx.cwd, done),
|
|
30
|
+
{ overlay: true },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (result === undefined) return;
|
|
34
|
+
|
|
35
|
+
const target = await resolveOrCreateDirectory(result.directory, ctx);
|
|
36
|
+
if (target !== null) {
|
|
37
|
+
await switchToNewSession(target, ctx);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a directory path. If it doesn't exist, prompt the user to create it.
|
|
43
|
+
* Returns the resolved path, or null if the user cancels or the path is invalid.
|
|
44
|
+
*/
|
|
45
|
+
async function resolveOrCreateDirectory(
|
|
46
|
+
input: string,
|
|
47
|
+
ctx: ExtensionCommandContext,
|
|
48
|
+
): Promise<string | null> {
|
|
49
|
+
// Try normal resolution first
|
|
50
|
+
const resolved = resolveDirectory(input, ctx.cwd);
|
|
51
|
+
if (resolved !== null) return resolved;
|
|
52
|
+
|
|
53
|
+
// Directory doesn't exist. Check if we can create it.
|
|
54
|
+
// Resolve the path to get the intended full path.
|
|
55
|
+
let targetPath: string;
|
|
56
|
+
if (input.startsWith("~/")) {
|
|
57
|
+
targetPath = path.join(os.homedir(), input.slice(2));
|
|
58
|
+
} else if (path.isAbsolute(input)) {
|
|
59
|
+
targetPath = path.normalize(input);
|
|
60
|
+
} else {
|
|
61
|
+
targetPath = path.resolve(ctx.cwd, input);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check if parent directory exists
|
|
65
|
+
const parentDir = path.dirname(targetPath);
|
|
66
|
+
if (!fs.existsSync(parentDir)) {
|
|
67
|
+
ctx.ui.notify(
|
|
68
|
+
`Cannot create "${path.basename(targetPath)}": parent directory does not exist`,
|
|
69
|
+
"error",
|
|
70
|
+
);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Prompt user to create it
|
|
75
|
+
const basename = path.basename(targetPath);
|
|
76
|
+
const confirmed = await ctx.ui.confirm(
|
|
77
|
+
"Create directory?",
|
|
78
|
+
`"${basename}" does not exist. Create it?`,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (!confirmed) return null;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
85
|
+
return targetPath;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
88
|
+
ctx.ui.notify(`Failed to create directory: ${message}`, "error");
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Switch to a fresh Pi session in the target directory.
|
|
95
|
+
*
|
|
96
|
+
* Strategy: Create a new session file in the CURRENT session directory
|
|
97
|
+
* (so /resume still scans the same folder) but with the target directory
|
|
98
|
+
* as the session's CWD. Then switch to that session.
|
|
99
|
+
*
|
|
100
|
+
* IMPORTANT: SessionManager.create() does NOT write the file to disk.
|
|
101
|
+
* We must write the session header ourselves before switchSession reads it.
|
|
102
|
+
*/
|
|
103
|
+
async function switchToNewSession(targetDir: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
104
|
+
ctx.ui.notify(`Moving to ${targetDir}...`, "info");
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// Create the session manager — this generates a file path but does NOT
|
|
108
|
+
// write the file. We need the file on disk so switchSession can open it.
|
|
109
|
+
const currentSessionDir = ctx.sessionManager.getSessionDir();
|
|
110
|
+
const newSession = SessionManager.create(targetDir, currentSessionDir);
|
|
111
|
+
const sessionFile = newSession.getSessionFile();
|
|
112
|
+
|
|
113
|
+
if (!sessionFile) {
|
|
114
|
+
ctx.ui.notify("Failed to create new session", "error");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Write the session header to disk. Without this, SessionManager.open()
|
|
119
|
+
// in switchSession cannot read the CWD from the header and falls back
|
|
120
|
+
// to process.cwd() — which is the OLD directory.
|
|
121
|
+
const sessionId = newSession.getSessionId();
|
|
122
|
+
const header = {
|
|
123
|
+
type: "session",
|
|
124
|
+
version: 3,
|
|
125
|
+
id: sessionId,
|
|
126
|
+
timestamp: new Date().toISOString(),
|
|
127
|
+
cwd: targetDir,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Ensure the session directory exists
|
|
131
|
+
if (!fs.existsSync(currentSessionDir)) {
|
|
132
|
+
fs.mkdirSync(currentSessionDir, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fs.writeFileSync(sessionFile, JSON.stringify(header) + "\n", "utf-8");
|
|
136
|
+
|
|
137
|
+
// Switch to the new session. Pi opens the file, reads cwd: targetDir
|
|
138
|
+
// from the header, and creates a fresh runtime with the target CWD.
|
|
139
|
+
await ctx.switchSession(sessionFile);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
142
|
+
ctx.ui.notify(`Failed to move: ${message}`, "error");
|
|
143
|
+
}
|
|
144
|
+
}
|
package/overlay.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CURSOR_MARKER, type Focusable, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { findDirectories } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result emitted by the move overlay when the user confirms a selection.
|
|
7
|
+
*/
|
|
8
|
+
export interface MoveOverlayResult {
|
|
9
|
+
/** The selected directory path (resolved, absolute). */
|
|
10
|
+
directory: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Overlay component for the /move command.
|
|
15
|
+
*
|
|
16
|
+
* Renders a floating modal with:
|
|
17
|
+
* - A text input field for typing a directory path
|
|
18
|
+
* - A list of matching directories below (updated on each keystroke)
|
|
19
|
+
* - Keyboard navigation: type to filter, ↑↓ to select, Enter to confirm, Esc to cancel
|
|
20
|
+
*/
|
|
21
|
+
export class MoveOverlay implements Focusable {
|
|
22
|
+
readonly width = 68;
|
|
23
|
+
readonly maxResults = 15;
|
|
24
|
+
|
|
25
|
+
/** Focusable interface — set by TUI when focus changes */
|
|
26
|
+
focused = false;
|
|
27
|
+
|
|
28
|
+
private input = "";
|
|
29
|
+
private cursor = 0;
|
|
30
|
+
private selectedIndex = 0;
|
|
31
|
+
private results: Array<{ value: string; label: string; description?: string }> = [];
|
|
32
|
+
private theme: Theme;
|
|
33
|
+
private done: (result: MoveOverlayResult | undefined) => void;
|
|
34
|
+
private cwd: string;
|
|
35
|
+
|
|
36
|
+
constructor(theme: Theme, cwd: string, done: (result: MoveOverlayResult | undefined) => void) {
|
|
37
|
+
this.theme = theme;
|
|
38
|
+
this.cwd = cwd;
|
|
39
|
+
this.done = done;
|
|
40
|
+
this.updateResults();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
handleInput(data: string): void {
|
|
44
|
+
if (matchesKey(data, "escape")) {
|
|
45
|
+
this.done(undefined);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (matchesKey(data, "return")) {
|
|
50
|
+
this.confirmSelection();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (matchesKey(data, "up")) {
|
|
55
|
+
if (this.results.length > 0) {
|
|
56
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (matchesKey(data, "down")) {
|
|
62
|
+
if (this.results.length > 0) {
|
|
63
|
+
this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (matchesKey(data, "tab")) {
|
|
69
|
+
// Tab auto-completes the selected item into the input
|
|
70
|
+
const selected = this.results[this.selectedIndex];
|
|
71
|
+
if (selected) {
|
|
72
|
+
this.input = selected.value;
|
|
73
|
+
this.cursor = this.input.length;
|
|
74
|
+
this.updateResults();
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (matchesKey(data, "backspace")) {
|
|
80
|
+
if (this.cursor > 0) {
|
|
81
|
+
this.input = this.input.slice(0, this.cursor - 1) + this.input.slice(this.cursor);
|
|
82
|
+
this.cursor--;
|
|
83
|
+
this.selectedIndex = 0;
|
|
84
|
+
this.updateResults();
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (matchesKey(data, "left")) {
|
|
90
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (matchesKey(data, "right")) {
|
|
95
|
+
this.cursor = Math.min(this.input.length, this.cursor + 1);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Regular character input
|
|
100
|
+
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
101
|
+
this.input = this.input.slice(0, this.cursor) + data + this.input.slice(this.cursor);
|
|
102
|
+
this.cursor++;
|
|
103
|
+
this.selectedIndex = 0;
|
|
104
|
+
this.updateResults();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
render(_width: number): string[] {
|
|
109
|
+
const w = this.width;
|
|
110
|
+
const th = this.theme;
|
|
111
|
+
const innerW = w - 2;
|
|
112
|
+
const lines: string[] = [];
|
|
113
|
+
|
|
114
|
+
const pad = (s: string, len: number) => {
|
|
115
|
+
const vis = visibleWidth(s);
|
|
116
|
+
return s + " ".repeat(Math.max(0, len - vis));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const row = (content: string) => {
|
|
120
|
+
const vis = visibleWidth(content);
|
|
121
|
+
return (
|
|
122
|
+
th.fg("border", "│") +
|
|
123
|
+
(vis > innerW ? content.slice(0, innerW) : pad(content, innerW)) +
|
|
124
|
+
th.fg("border", "│")
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// ── Top border ──
|
|
129
|
+
lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
|
|
130
|
+
|
|
131
|
+
// ── Title ──
|
|
132
|
+
lines.push(row(` ${th.fg("accent", "📂 Move to directory")}`));
|
|
133
|
+
lines.push(row(""));
|
|
134
|
+
|
|
135
|
+
// ── Input field ──
|
|
136
|
+
const inputPrompt = th.fg("text", " Path: ");
|
|
137
|
+
let inputDisplay = this.input;
|
|
138
|
+
if (this.input.length > 0) {
|
|
139
|
+
const before = inputDisplay.slice(0, this.cursor);
|
|
140
|
+
const cursorChar = this.cursor < inputDisplay.length ? inputDisplay[this.cursor] : " ";
|
|
141
|
+
const after = inputDisplay.slice(this.cursor + 1);
|
|
142
|
+
const marker = this.focused ? CURSOR_MARKER : "";
|
|
143
|
+
inputDisplay = `${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
|
|
144
|
+
} else {
|
|
145
|
+
const placeholder = th.fg("dim", "Type a directory path...");
|
|
146
|
+
const marker = this.focused ? CURSOR_MARKER : "";
|
|
147
|
+
inputDisplay = `${placeholder}${marker}\x1b[7m \x1b[27m`;
|
|
148
|
+
}
|
|
149
|
+
lines.push(row(`${inputPrompt}${inputDisplay}`));
|
|
150
|
+
lines.push(row(""));
|
|
151
|
+
|
|
152
|
+
// ── Results list ──
|
|
153
|
+
if (this.results.length === 0 && this.input.length > 0) {
|
|
154
|
+
lines.push(row(` ${th.fg("dim", "No matching directories")}`));
|
|
155
|
+
} else {
|
|
156
|
+
const visibleResults = this.results.slice(0, this.maxResults);
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < visibleResults.length; i++) {
|
|
159
|
+
const item = visibleResults[i];
|
|
160
|
+
if (!item) continue;
|
|
161
|
+
const isSelected = i === this.selectedIndex;
|
|
162
|
+
|
|
163
|
+
const prefix = isSelected ? th.fg("accent", " ▶") : " ";
|
|
164
|
+
const label = isSelected ? th.fg("accent", item.label) : th.fg("text", item.label);
|
|
165
|
+
|
|
166
|
+
const desc = item.description ? th.fg("dim", ` ${item.description}`) : "";
|
|
167
|
+
|
|
168
|
+
lines.push(row(`${prefix} ${label}${desc}`));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
lines.push(row(""));
|
|
173
|
+
|
|
174
|
+
// ── Footer ──
|
|
175
|
+
lines.push(row(` ${th.fg("dim", "Type to filter · ↑↓·Tab·Enter·Esc")}`));
|
|
176
|
+
|
|
177
|
+
// ── Bottom border ──
|
|
178
|
+
lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
179
|
+
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
invalidate(): void {
|
|
184
|
+
// No external state to invalidate
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
dispose(): void {
|
|
188
|
+
// No resources to clean up
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private updateResults(): void {
|
|
192
|
+
this.results = findDirectories(this.input, this.cwd, this.maxResults + 5);
|
|
193
|
+
if (this.selectedIndex >= this.results.length) {
|
|
194
|
+
this.selectedIndex = Math.max(0, this.results.length - 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private confirmSelection(): void {
|
|
199
|
+
// If there's a selection, use it
|
|
200
|
+
const selectedItem = this.results[this.selectedIndex];
|
|
201
|
+
if (selectedItem) {
|
|
202
|
+
this.done({ directory: selectedItem.value });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// If input is non-empty, try resolving it directly
|
|
207
|
+
if (this.input.trim().length > 0) {
|
|
208
|
+
// Resolve will be done by the handler
|
|
209
|
+
this.done({ directory: this.input.trim() });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Nothing to select
|
|
214
|
+
this.done(undefined);
|
|
215
|
+
}
|
|
216
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@k3_2o/pi-move",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension — /move command to switch directories from inside Pi",
|
|
5
|
+
"keywords": ["pi-package", "extension", "directory", "navigation"],
|
|
6
|
+
"author": "k3_2o",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"fmt": "prettier --write '*.ts'",
|
|
11
|
+
"fmt:check": "prettier --check '*.ts'",
|
|
12
|
+
"lint": "eslint '*.ts'",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"check": "npm run fmt:check && npm run lint && npm run typecheck",
|
|
15
|
+
"security": "npm audit --audit-level=high"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"eslint": "^9.0.0",
|
|
19
|
+
"prettier": "^3.5.3",
|
|
20
|
+
"typescript": "^5.7.0",
|
|
21
|
+
"typescript-eslint": "^8.0.0"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/k3-2o/pi-move.git"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": ["./index.ts"]
|
|
32
|
+
}
|
|
33
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noUncheckedIndexedAccess": true,
|
|
8
|
+
"noUnusedLocals": true,
|
|
9
|
+
"noUnusedParameters": true,
|
|
10
|
+
"exactOptionalPropertyTypes": false,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"declaration": false,
|
|
14
|
+
"outDir": "dist",
|
|
15
|
+
"baseUrl": ".",
|
|
16
|
+
"paths": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": [
|
|
18
|
+
"/home/k2/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts"
|
|
19
|
+
],
|
|
20
|
+
"@earendil-works/pi-coding-agent/*": [
|
|
21
|
+
"/home/k2/node_modules/@earendil-works/pi-coding-agent/dist/*.d.ts"
|
|
22
|
+
],
|
|
23
|
+
"@earendil-works/pi-tui": [
|
|
24
|
+
"/home/k2/node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui/dist/index.d.ts"
|
|
25
|
+
],
|
|
26
|
+
"@earendil-works/pi-tui/*": [
|
|
27
|
+
"/home/k2/node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui/dist/*.d.ts"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"include": ["*.ts", "scripts/**/*.ts"],
|
|
32
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
33
|
+
}
|
package/utils.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a user-provided directory path against the given CWD.
|
|
8
|
+
* Handles ~ expansion, relative paths, . and .. segments.
|
|
9
|
+
*
|
|
10
|
+
* Returns null if the resolved path doesn't exist or isn't a directory.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveDirectory(input: string, cwd: string): string | null {
|
|
13
|
+
let resolved: string;
|
|
14
|
+
|
|
15
|
+
if (input.startsWith("~/") || input === "~") {
|
|
16
|
+
resolved = path.join(os.homedir(), input.slice(1));
|
|
17
|
+
} else if (path.isAbsolute(input)) {
|
|
18
|
+
resolved = input;
|
|
19
|
+
} else {
|
|
20
|
+
resolved = path.resolve(cwd, input);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
resolved = path.normalize(resolved);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
if (fs.statSync(resolved).isDirectory()) {
|
|
27
|
+
return resolved;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Does not exist or permission denied
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get a sorted list of directories matching the given prefix.
|
|
38
|
+
*
|
|
39
|
+
* Uses `fd` for fast, recursive, .gitignore-respecting search when available.
|
|
40
|
+
* Falls back to a basic readdirSync scan if fd isn't installed.
|
|
41
|
+
*/
|
|
42
|
+
export function findDirectories(
|
|
43
|
+
prefix: string,
|
|
44
|
+
cwd: string,
|
|
45
|
+
maxResults = 30,
|
|
46
|
+
): Array<{ value: string; label: string; description?: string }> {
|
|
47
|
+
const resolved = resolveDirectory(prefix, cwd);
|
|
48
|
+
if (resolved !== null && fs.statSync(resolved).isDirectory()) {
|
|
49
|
+
// If prefix is already a valid directory, list its contents
|
|
50
|
+
return listDirectoryContents(resolved, maxResults);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fuzzy search from cwd (or from the partial path)
|
|
54
|
+
const searchDir = getSearchBase(prefix, cwd);
|
|
55
|
+
const query = getQuery(prefix);
|
|
56
|
+
|
|
57
|
+
return searchDirectories(searchDir, query, maxResults);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Search for directories using fd (fast) or readdirSync (fallback).
|
|
62
|
+
*/
|
|
63
|
+
async function searchDirectoriesFd(
|
|
64
|
+
baseDir: string,
|
|
65
|
+
query: string,
|
|
66
|
+
maxResults: number,
|
|
67
|
+
): Promise<Array<{ path: string; isDirectory: boolean }>> {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const args = [
|
|
70
|
+
"--base-directory",
|
|
71
|
+
baseDir,
|
|
72
|
+
"--max-results",
|
|
73
|
+
String(maxResults),
|
|
74
|
+
"--type",
|
|
75
|
+
"d",
|
|
76
|
+
"--follow",
|
|
77
|
+
"--hidden",
|
|
78
|
+
"--exclude",
|
|
79
|
+
".git",
|
|
80
|
+
"--exclude",
|
|
81
|
+
"node_modules",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
if (query) {
|
|
85
|
+
args.push(query);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const child = spawn("fd", args, {
|
|
89
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let stdout = "";
|
|
93
|
+
|
|
94
|
+
child.stdout.setEncoding("utf-8");
|
|
95
|
+
child.stdout.on("data", (chunk: string) => {
|
|
96
|
+
stdout += chunk;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
child.on("error", () => {
|
|
100
|
+
resolve([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
child.on("close", (code) => {
|
|
104
|
+
if (code !== 0 || !stdout) {
|
|
105
|
+
resolve([]);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
110
|
+
const results = lines.map((line) => {
|
|
111
|
+
const normalized = line.replace(/\\/g, "/");
|
|
112
|
+
const isDir = normalized.endsWith("/");
|
|
113
|
+
return {
|
|
114
|
+
path: isDir ? normalized.slice(0, -1) : normalized,
|
|
115
|
+
isDirectory: true,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
resolve(results);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* List directory contents (all subdirectories).
|
|
126
|
+
*/
|
|
127
|
+
function listDirectoryContents(
|
|
128
|
+
dirPath: string,
|
|
129
|
+
maxResults: number,
|
|
130
|
+
): Array<{ value: string; label: string; description?: string }> {
|
|
131
|
+
try {
|
|
132
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
133
|
+
const results: Array<{ value: string; label: string; description?: string }> = [];
|
|
134
|
+
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
if (results.length >= maxResults) break;
|
|
137
|
+
if (entry.name.startsWith(".")) continue; // skip hidden
|
|
138
|
+
|
|
139
|
+
let isDir: boolean;
|
|
140
|
+
try {
|
|
141
|
+
isDir = entry.isDirectory();
|
|
142
|
+
if (!isDir && entry.isSymbolicLink()) {
|
|
143
|
+
isDir = fs.statSync(path.join(dirPath, entry.name)).isDirectory();
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (isDir) {
|
|
150
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
151
|
+
results.push({
|
|
152
|
+
value: fullPath,
|
|
153
|
+
label: entry.name + "/",
|
|
154
|
+
description: fullPath,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Alphabetical sort
|
|
160
|
+
results.sort((a, b) => a.label.localeCompare(b.label));
|
|
161
|
+
return results;
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Search directories using readdirSync recursively (fallback when fd is unavailable).
|
|
169
|
+
*/
|
|
170
|
+
function searchDirectories(
|
|
171
|
+
baseDir: string,
|
|
172
|
+
query: string,
|
|
173
|
+
maxResults: number,
|
|
174
|
+
): Array<{ value: string; label: string; description?: string }> {
|
|
175
|
+
// First, try fd for speed
|
|
176
|
+
searchDirectoriesFd(baseDir, query, maxResults)
|
|
177
|
+
.then((fdResults) => {
|
|
178
|
+
if (fdResults.length > 0) {
|
|
179
|
+
return fdResults.map((r) => ({
|
|
180
|
+
value: path.resolve(baseDir, r.path),
|
|
181
|
+
label: r.path + "/",
|
|
182
|
+
description: path.resolve(baseDir, r.path),
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
})
|
|
187
|
+
.catch(() => null);
|
|
188
|
+
|
|
189
|
+
// Synchronous fallback: iterate parent directories
|
|
190
|
+
// This is simpler and immediate
|
|
191
|
+
const results: Array<{ value: string; label: string; description?: string }> = [];
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
|
|
195
|
+
const lowerQuery = query.toLowerCase();
|
|
196
|
+
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (results.length >= maxResults) break;
|
|
199
|
+
|
|
200
|
+
let isDir: boolean;
|
|
201
|
+
try {
|
|
202
|
+
isDir = entry.isDirectory();
|
|
203
|
+
if (!isDir && entry.isSymbolicLink()) {
|
|
204
|
+
isDir = fs.statSync(path.join(baseDir, entry.name)).isDirectory();
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!isDir) continue;
|
|
211
|
+
|
|
212
|
+
if (query === "" || entry.name.toLowerCase().includes(lowerQuery)) {
|
|
213
|
+
const fullPath = path.join(baseDir, entry.name);
|
|
214
|
+
results.push({
|
|
215
|
+
value: fullPath,
|
|
216
|
+
label: entry.name + "/",
|
|
217
|
+
description: fullPath,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// Permission denied or non-existent
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Extract the search base directory and the query part from a path prefix.
|
|
230
|
+
* E.g., "~/projects/my" → base: "~/projects", query: "my"
|
|
231
|
+
* "foo/bar" → base: "cwd/foo", query: "bar"
|
|
232
|
+
*/
|
|
233
|
+
function getSearchBase(prefix: string, cwd: string): string {
|
|
234
|
+
if (prefix === "" || prefix === "~") {
|
|
235
|
+
return cwd;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const normalized = prefix.replace(/\\/g, "/");
|
|
239
|
+
const lastSlash = normalized.lastIndexOf("/");
|
|
240
|
+
|
|
241
|
+
if (lastSlash === -1) {
|
|
242
|
+
return cwd;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const basePart = normalized.slice(0, lastSlash + 1);
|
|
246
|
+
|
|
247
|
+
if (basePart.startsWith("~")) {
|
|
248
|
+
return path.join(os.homedir(), basePart.slice(1));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (path.isAbsolute(basePart)) {
|
|
252
|
+
return basePart;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return path.resolve(cwd, basePart);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function getQuery(prefix: string): string {
|
|
259
|
+
if (prefix === "" || prefix === "~") return "";
|
|
260
|
+
const normalized = prefix.replace(/\\/g, "/");
|
|
261
|
+
const lastSlash = normalized.lastIndexOf("/");
|
|
262
|
+
if (lastSlash === -1) return normalized;
|
|
263
|
+
return normalized.slice(lastSlash + 1);
|
|
264
|
+
}
|