@ff-labs/pi-fff 0.6.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/README.md +152 -0
- package/package.json +53 -0
- package/src/index.ts +820 -0
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# @ff-labs/pi-fff
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/badlogic/pi-mono) extension that replaces the built-in `find` and `grep` tools with [FFF](https://github.com/dmtrKovalenko/fff.nvim) — a Rust-native, SIMD-accelerated file finder with built-in memory.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
| Built-in tool | pi-fff replacement | Improvement |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `find` (spawns `fd`) | `fffind` (FFF `fileSearch`) | Fuzzy matching, frecency ranking, git-aware, pre-indexed |
|
|
10
|
+
| `grep` (spawns `rg`) | `ffgrep` (FFF `grep`) | SIMD-accelerated, frecency-ordered, mmap-cached, no subprocess |
|
|
11
|
+
| *(none)* | `fff-multi-grep` (FFF `multiGrep`) | OR-logic multi-pattern search via Aho-Corasick |
|
|
12
|
+
| `@` file autocomplete (fd-backed) | `@` file autocomplete (FFF-backed, default) | Fuzzy ranking from FFF index/frecency |
|
|
13
|
+
|
|
14
|
+
### Key advantages over built-in tools
|
|
15
|
+
|
|
16
|
+
- **No subprocess spawning** — FFF is a Rust native library called through the Node binding. No `fd`/`rg` process per call.
|
|
17
|
+
- **Pre-indexed** — files are indexed in the background at session start. Searches are instant.
|
|
18
|
+
- **Frecency ranking** — files you access often rank higher. Learns across sessions.
|
|
19
|
+
- **Query history** — remembers which files were selected for which queries. Combo boost.
|
|
20
|
+
- **Git-aware** — modified/staged/untracked files are boosted in results.
|
|
21
|
+
- **Smart case** — case-insensitive when query is all lowercase, case-sensitive otherwise.
|
|
22
|
+
- **Fuzzy file search** — `find` uses fuzzy matching, not glob-only. Typo-tolerant.
|
|
23
|
+
- **Cursor pagination** — grep results include a cursor for fetching the next page.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Requirements:
|
|
28
|
+
- pi
|
|
29
|
+
|
|
30
|
+
### Install as a pi package
|
|
31
|
+
|
|
32
|
+
**Via npm (recommended):**
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install npm:@ff-labs/pi-fff
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Project-local install:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pi install -l npm:@ff-labs/pi-fff
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Via git:**
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pi install git:github.com/dmtrKovalenko/fff.nvim
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Pin to a release:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pi install git:github.com/dmtrKovalenko/fff.nvim@v0.3.0
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Local development / manual install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/dmtrKovalenko/fff.nvim.git
|
|
60
|
+
cd fff.nvim/packages/pi-fff
|
|
61
|
+
npm install
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then add to your pi `settings.json`:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"extensions": ["/path/to/fff.nvim/packages/pi-fff/src/index.ts"]
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or test directly:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pi -e /path/to/fff.nvim/packages/pi-fff/src/index.ts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This extension registers FFF-powered tools (`fffind`, `ffgrep`, `fff-multi-grep`) alongside pi's built-in tools.
|
|
79
|
+
|
|
80
|
+
## Tools
|
|
81
|
+
|
|
82
|
+
### `ffgrep`
|
|
83
|
+
|
|
84
|
+
Search file contents. Smart case, plain text by default, regex optional.
|
|
85
|
+
|
|
86
|
+
Parameters:
|
|
87
|
+
- `pattern` — search text or regex
|
|
88
|
+
- `path` — directory/file constraint (e.g. `src/`, `*.ts`)
|
|
89
|
+
- `ignoreCase` — force case-insensitive
|
|
90
|
+
- `literal` — treat as literal string (default: true)
|
|
91
|
+
- `context` — context lines around matches
|
|
92
|
+
- `limit` — max matches (default: 100)
|
|
93
|
+
- `cursor` — pagination cursor from previous result
|
|
94
|
+
|
|
95
|
+
### `fffind`
|
|
96
|
+
|
|
97
|
+
Fuzzy file name search. Frecency-ranked.
|
|
98
|
+
|
|
99
|
+
Parameters:
|
|
100
|
+
- `pattern` — fuzzy query (e.g. `main.ts`, `src/ config`)
|
|
101
|
+
- `path` — directory constraint
|
|
102
|
+
- `limit` — max results (default: 200)
|
|
103
|
+
|
|
104
|
+
### `fff-multi-grep`
|
|
105
|
+
|
|
106
|
+
OR-logic multi-pattern content search. SIMD-accelerated Aho-Corasick.
|
|
107
|
+
|
|
108
|
+
Parameters:
|
|
109
|
+
- `patterns` — array of literal patterns (OR logic)
|
|
110
|
+
- `constraints` — file constraints (e.g. `*.{ts,tsx} !test/`)
|
|
111
|
+
- `context` — context lines
|
|
112
|
+
- `limit` — max matches (default: 100)
|
|
113
|
+
- `cursor` — pagination cursor
|
|
114
|
+
|
|
115
|
+
## Commands
|
|
116
|
+
|
|
117
|
+
- `/fff-health` — show FFF status (indexed files, git info, frecency/history DB status)
|
|
118
|
+
- `/fff-rescan` — trigger a file rescan
|
|
119
|
+
- `/fff-mode <mode>` — switch mode (tool name change requires restart)
|
|
120
|
+
|
|
121
|
+
## Modes
|
|
122
|
+
|
|
123
|
+
- `tools-and-ui` (default): registers `fffind`, `ffgrep`, `fff-multi-grep` as additional tools + FFF-backed `@` autocomplete
|
|
124
|
+
- `tools-only`: additional tools only; keep pi's default `@` autocomplete
|
|
125
|
+
- `override`: replaces pi's built-in `find`, `grep` and adds `multi_grep` + FFF-backed `@` autocomplete
|
|
126
|
+
|
|
127
|
+
Mode precedence:
|
|
128
|
+
1. `--fff-mode <mode>` CLI flag
|
|
129
|
+
2. `PI_FFF_MODE=<mode>` environment variable
|
|
130
|
+
3. default (`tools-and-ui`)
|
|
131
|
+
|
|
132
|
+
## Flags
|
|
133
|
+
|
|
134
|
+
- `--fff-mode <mode>` — set mode (see above)
|
|
135
|
+
- `--fff-frecency-db <path>` — path to frecency database (also: `FFF_FRECENCY_DB` env)
|
|
136
|
+
- `--fff-history-db <path>` — path to query history database (also: `FFF_HISTORY_DB` env)
|
|
137
|
+
|
|
138
|
+
## Data
|
|
139
|
+
|
|
140
|
+
When database paths are provided, FFF stores:
|
|
141
|
+
- frecency database — file access frequency/recency
|
|
142
|
+
- history database — query-to-file selection history
|
|
143
|
+
|
|
144
|
+
No project files are uploaded anywhere by this extension. It runs locally and only uses the configured LLM through pi itself.
|
|
145
|
+
|
|
146
|
+
## Security
|
|
147
|
+
|
|
148
|
+
- No shell execution
|
|
149
|
+
- No network calls in the extension code
|
|
150
|
+
- No telemetry
|
|
151
|
+
- No credential handling beyond whatever pi and your configured model provider already do
|
|
152
|
+
- Search state is stored locally under `~/.pi/agent/fff/`
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ff-labs/pi-fff",
|
|
3
|
+
"public": true,
|
|
4
|
+
"version": "0.6.0",
|
|
5
|
+
"description": "pi extension: FFF-powered fuzzy file and content search",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
|
11
|
+
"directory": "packages/pi-fff"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/dmtrKovalenko/fff.nvim/tree/main/packages/pi-fff",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/dmtrKovalenko/fff.nvim/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-package",
|
|
20
|
+
"pi-extension",
|
|
21
|
+
"fff",
|
|
22
|
+
"search",
|
|
23
|
+
"grep",
|
|
24
|
+
"fuzzy-search",
|
|
25
|
+
"ai-agent"
|
|
26
|
+
],
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./src/index.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@ff-labs/fff-node": "^0.6.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
46
|
+
"@mariozechner/pi-tui": "*",
|
|
47
|
+
"@sinclair/typebox": "*"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"typescript": "^5.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-fff: FFF-powered file search extension for pi
|
|
3
|
+
*
|
|
4
|
+
* Overrides built-in `find` and `grep` tools with FFF and can also replace
|
|
5
|
+
* @-mention autocomplete suggestions in the interactive editor.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
CustomEditor,
|
|
11
|
+
truncateHead,
|
|
12
|
+
DEFAULT_MAX_BYTES,
|
|
13
|
+
formatSize,
|
|
14
|
+
} from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import {
|
|
16
|
+
Text,
|
|
17
|
+
type AutocompleteItem,
|
|
18
|
+
type AutocompleteProvider,
|
|
19
|
+
} from "@mariozechner/pi-tui";
|
|
20
|
+
import { Type } from "@sinclair/typebox";
|
|
21
|
+
import { FileFinder } from "@ff-labs/fff-node";
|
|
22
|
+
import type {
|
|
23
|
+
GrepCursor,
|
|
24
|
+
GrepMode,
|
|
25
|
+
GrepResult,
|
|
26
|
+
SearchResult,
|
|
27
|
+
MixedItem,
|
|
28
|
+
} from "@ff-labs/fff-node";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Constants
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const DEFAULT_GREP_LIMIT = 100;
|
|
35
|
+
const DEFAULT_FIND_LIMIT = 200;
|
|
36
|
+
const GREP_MAX_LINE_LENGTH = 500;
|
|
37
|
+
const MENTION_MAX_RESULTS = 20;
|
|
38
|
+
|
|
39
|
+
type FffMode = "tools-and-ui" | "tools-only" | "override";
|
|
40
|
+
|
|
41
|
+
const VALID_MODES: FffMode[] = ["tools-and-ui", "tools-only", "override"];
|
|
42
|
+
|
|
43
|
+
interface ToolNames {
|
|
44
|
+
grep: string;
|
|
45
|
+
find: string;
|
|
46
|
+
multiGrep: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FFF_TOOL_NAMES: ToolNames = {
|
|
50
|
+
grep: "ffgrep",
|
|
51
|
+
find: "fffind",
|
|
52
|
+
multiGrep: "fff-multi-grep",
|
|
53
|
+
};
|
|
54
|
+
const OVERRIDE_TOOL_NAMES: ToolNames = {
|
|
55
|
+
grep: "grep",
|
|
56
|
+
find: "find",
|
|
57
|
+
multiGrep: "multi_grep",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function resolveToolNames(mode: FffMode): ToolNames {
|
|
61
|
+
return mode === "override" ? OVERRIDE_TOOL_NAMES : FFF_TOOL_NAMES;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Cursor store — simple bounded Map for pagination cursors
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const cursorCache = new Map<string, GrepCursor>();
|
|
69
|
+
let cursorCounter = 0;
|
|
70
|
+
|
|
71
|
+
function storeCursor(cursor: GrepCursor): string {
|
|
72
|
+
const id = `fff_c${++cursorCounter}`;
|
|
73
|
+
cursorCache.set(id, cursor);
|
|
74
|
+
if (cursorCache.size > 200) {
|
|
75
|
+
const first = cursorCache.keys().next().value;
|
|
76
|
+
if (first) cursorCache.delete(first);
|
|
77
|
+
}
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getCursor(id: string): GrepCursor | undefined {
|
|
82
|
+
return cursorCache.get(id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Output formatting helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function truncateLine(line: string, max = GREP_MAX_LINE_LENGTH): string {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max)}...`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatGrepOutput(result: GrepResult, limit: number): string {
|
|
95
|
+
const items = result.items.slice(0, limit);
|
|
96
|
+
if (items.length === 0) return "No matches found";
|
|
97
|
+
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
let currentFile = "";
|
|
100
|
+
|
|
101
|
+
for (const match of items) {
|
|
102
|
+
if (match.relativePath != currentFile) {
|
|
103
|
+
currentFile = match.relativePath;
|
|
104
|
+
if (lines.length > 0) lines.push("");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
match.contextBefore?.forEach((line: string, i: number) => {
|
|
108
|
+
lines.push(
|
|
109
|
+
`${match.relativePath}-${match.lineNumber - match.contextBefore!.length + i}- ${truncateLine(line)}`,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
lines.push(
|
|
114
|
+
`${match.relativePath}:${match.lineNumber}: ${truncateLine(match.lineContent)}`,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
match.contextAfter?.forEach((line: string, i: number) => {
|
|
118
|
+
lines.push(
|
|
119
|
+
`${match.relativePath}-${match.lineNumber + 1 + i}- ${truncateLine(line)}`,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatFindOutput(result: SearchResult, limit: number): string {
|
|
128
|
+
const items = result.items.slice(0, limit);
|
|
129
|
+
return items.length === 0
|
|
130
|
+
? "No files found matching pattern"
|
|
131
|
+
: items.map((i: { relativePath: string }) => i.relativePath).join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Mention autocomplete helpers
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function extractAtPrefix(textBeforeCursor: string): string | null {
|
|
139
|
+
const match = textBeforeCursor.match(/(?:^|[ \t])(@(?:"[^"]*|[^\s]*))$/);
|
|
140
|
+
return match?.[1] ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildAtCompletionValue(path: string): string {
|
|
144
|
+
return path.includes(" ") ? `@"${path}"` : `@${path}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createFffMentionProvider(
|
|
148
|
+
getItems: (query: string, signal: AbortSignal) => Promise<AutocompleteItem[]>,
|
|
149
|
+
): AutocompleteProvider {
|
|
150
|
+
return {
|
|
151
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
152
|
+
const currentLine = lines[cursorLine] || "";
|
|
153
|
+
const prefix = extractAtPrefix(currentLine.slice(0, cursorCol));
|
|
154
|
+
if (!prefix || options.signal.aborted) return null;
|
|
155
|
+
|
|
156
|
+
const query = prefix.startsWith('@"') ? prefix.slice(2) : prefix.slice(1);
|
|
157
|
+
const items = await getItems(query, options.signal);
|
|
158
|
+
return options.signal.aborted || items.length === 0 ? null : { items, prefix };
|
|
159
|
+
},
|
|
160
|
+
applyCompletion(_lines, cursorLine, cursorCol, item, prefix) {
|
|
161
|
+
const currentLine = _lines[cursorLine] || "";
|
|
162
|
+
const before = currentLine.slice(0, cursorCol - prefix.length);
|
|
163
|
+
const after = currentLine.slice(cursorCol);
|
|
164
|
+
const newLine = before + item.value + after;
|
|
165
|
+
const newCursorCol = cursorCol - prefix.length + item.value.length;
|
|
166
|
+
return {
|
|
167
|
+
lines: [..._lines.slice(0, cursorLine), newLine, ..._lines.slice(cursorLine + 1)],
|
|
168
|
+
cursorLine,
|
|
169
|
+
cursorCol: newCursorCol,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Simple editor wrapper that injects FFF @-mention autocomplete alongside base provider
|
|
176
|
+
class FffEditor extends CustomEditor {
|
|
177
|
+
private baseProvider: AutocompleteProvider | undefined;
|
|
178
|
+
private getMentionItems: (
|
|
179
|
+
query: string,
|
|
180
|
+
signal: AbortSignal,
|
|
181
|
+
) => Promise<AutocompleteItem[]>;
|
|
182
|
+
|
|
183
|
+
constructor(
|
|
184
|
+
tui: any,
|
|
185
|
+
theme: any,
|
|
186
|
+
keybindings: any,
|
|
187
|
+
getMentionItems: (query: string, signal: AbortSignal) => Promise<AutocompleteItem[]>,
|
|
188
|
+
) {
|
|
189
|
+
super(tui, theme, keybindings);
|
|
190
|
+
this.getMentionItems = getMentionItems;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
override setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
194
|
+
this.baseProvider = provider;
|
|
195
|
+
// Create composite provider that handles @-mentions and falls back to base
|
|
196
|
+
const mentionProvider = createFffMentionProvider(this.getMentionItems);
|
|
197
|
+
const compositeProvider: AutocompleteProvider = {
|
|
198
|
+
getSuggestions: async (lines, cursorLine, cursorCol, options) => {
|
|
199
|
+
// Try @-mention first
|
|
200
|
+
const mentionResult = await mentionProvider.getSuggestions(
|
|
201
|
+
lines,
|
|
202
|
+
cursorLine,
|
|
203
|
+
cursorCol,
|
|
204
|
+
options,
|
|
205
|
+
);
|
|
206
|
+
if (mentionResult) return mentionResult;
|
|
207
|
+
// Fall back to base provider
|
|
208
|
+
return (
|
|
209
|
+
this.baseProvider?.getSuggestions(lines, cursorLine, cursorCol, options) ?? null
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
|
213
|
+
// Let mention provider handle @ completions, base provider for others
|
|
214
|
+
if (prefix?.startsWith("@")) {
|
|
215
|
+
return mentionProvider.applyCompletion!(
|
|
216
|
+
lines,
|
|
217
|
+
cursorLine,
|
|
218
|
+
cursorCol,
|
|
219
|
+
item,
|
|
220
|
+
prefix,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return (
|
|
224
|
+
this.baseProvider?.applyCompletion?.(
|
|
225
|
+
lines,
|
|
226
|
+
cursorLine,
|
|
227
|
+
cursorCol,
|
|
228
|
+
item,
|
|
229
|
+
prefix,
|
|
230
|
+
) ?? { lines, cursorLine, cursorCol }
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
super.setAutocompleteProvider(compositeProvider);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Extension
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
export default function fffExtension(pi: ExtensionAPI) {
|
|
243
|
+
let finder: FileFinder | null = null;
|
|
244
|
+
let finderCwd: string | null = null;
|
|
245
|
+
let activeCwd = process.cwd();
|
|
246
|
+
|
|
247
|
+
// Mode resolution: flag > env > default
|
|
248
|
+
let currentMode: FffMode =
|
|
249
|
+
(pi.getFlag("fff-mode") as FffMode) ??
|
|
250
|
+
(process.env.PI_FFF_MODE as FffMode) ??
|
|
251
|
+
"tools-and-ui";
|
|
252
|
+
|
|
253
|
+
const toolNames = resolveToolNames(currentMode);
|
|
254
|
+
|
|
255
|
+
// DB path resolution: flag > env > undefined (use fff-node defaults)
|
|
256
|
+
const frecencyDbPath =
|
|
257
|
+
(pi.getFlag("fff-frecency-db") as string | undefined) ??
|
|
258
|
+
process.env.FFF_FRECENCY_DB ??
|
|
259
|
+
undefined;
|
|
260
|
+
const historyDbPath =
|
|
261
|
+
(pi.getFlag("fff-history-db") as string | undefined) ??
|
|
262
|
+
process.env.FFF_HISTORY_DB ??
|
|
263
|
+
undefined;
|
|
264
|
+
|
|
265
|
+
function getMode(): FffMode {
|
|
266
|
+
return currentMode;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function setMode(mode: FffMode): void {
|
|
270
|
+
currentMode = mode;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function shouldEnableMentions(): boolean {
|
|
274
|
+
return currentMode !== "tools-only";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function ensureFinder(cwd: string): Promise<FileFinder> {
|
|
278
|
+
if (finder && !finder.isDestroyed && finderCwd === cwd) return finder;
|
|
279
|
+
if (finder && !finder.isDestroyed) {
|
|
280
|
+
finder.destroy();
|
|
281
|
+
finder = null;
|
|
282
|
+
finderCwd = null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const result = FileFinder.create({
|
|
286
|
+
basePath: cwd,
|
|
287
|
+
frecencyDbPath,
|
|
288
|
+
historyDbPath,
|
|
289
|
+
aiMode: true,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (!result.ok) throw new Error(`Failed to create FFF file finder: ${result.error}`);
|
|
293
|
+
|
|
294
|
+
finder = result.value;
|
|
295
|
+
finderCwd = cwd;
|
|
296
|
+
await finder.waitForScan(15000);
|
|
297
|
+
return finder;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function destroyFinder() {
|
|
301
|
+
if (finder && !finder.isDestroyed) {
|
|
302
|
+
finder.destroy();
|
|
303
|
+
finder = null;
|
|
304
|
+
finderCwd = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function getMentionItems(
|
|
309
|
+
query: string,
|
|
310
|
+
signal: AbortSignal,
|
|
311
|
+
): Promise<AutocompleteItem[]> {
|
|
312
|
+
if (signal.aborted) return [];
|
|
313
|
+
const f = await ensureFinder(activeCwd);
|
|
314
|
+
if (signal.aborted) return [];
|
|
315
|
+
|
|
316
|
+
const result = f.mixedSearch(query, { pageSize: MENTION_MAX_RESULTS });
|
|
317
|
+
if (!result.ok) return [];
|
|
318
|
+
|
|
319
|
+
return result.value.items.slice(0, MENTION_MAX_RESULTS).map((mixed: MixedItem) => {
|
|
320
|
+
if (mixed.type === "directory") {
|
|
321
|
+
return {
|
|
322
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
323
|
+
label: mixed.item.dirName,
|
|
324
|
+
description: mixed.item.relativePath,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
value: buildAtCompletionValue(mixed.item.relativePath),
|
|
329
|
+
label: mixed.item.fileName,
|
|
330
|
+
description: mixed.item.relativePath,
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function applyEditorMode(ctx: {
|
|
336
|
+
ui: {
|
|
337
|
+
setEditorComponent: (
|
|
338
|
+
factory: ((tui: any, theme: any, keybindings: any) => any) | undefined,
|
|
339
|
+
) => void;
|
|
340
|
+
};
|
|
341
|
+
}) {
|
|
342
|
+
if (!shouldEnableMentions()) {
|
|
343
|
+
ctx.ui.setEditorComponent(undefined);
|
|
344
|
+
} else {
|
|
345
|
+
ctx.ui.setEditorComponent(
|
|
346
|
+
(tui: any, theme: any, keybindings: any) =>
|
|
347
|
+
new FffEditor(tui, theme, keybindings, getMentionItems),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- Flags / lifecycle ---
|
|
353
|
+
|
|
354
|
+
pi.registerFlag("fff-mode", {
|
|
355
|
+
description: "FFF mode: tools-and-ui | tools-only | override",
|
|
356
|
+
type: "string",
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
pi.registerFlag("fff-frecency-db", {
|
|
360
|
+
description: "Path to the frecency database (overrides FFF_FRECENCY_DB env)",
|
|
361
|
+
type: "string",
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
pi.registerFlag("fff-history-db", {
|
|
365
|
+
description: "Path to the query history database (overrides FFF_HISTORY_DB env)",
|
|
366
|
+
type: "string",
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
370
|
+
try {
|
|
371
|
+
activeCwd = ctx.cwd;
|
|
372
|
+
await ensureFinder(activeCwd);
|
|
373
|
+
applyEditorMode(ctx);
|
|
374
|
+
} catch (e: unknown) {
|
|
375
|
+
ctx.ui.notify(
|
|
376
|
+
`FFF init failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
377
|
+
"error",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
pi.on("session_shutdown", async () => {
|
|
383
|
+
destroyFinder();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// --- Shared render helpers ---
|
|
387
|
+
|
|
388
|
+
const renderTextResult = (
|
|
389
|
+
result: { content?: { type: string; text?: string }[] },
|
|
390
|
+
options: { expanded?: boolean },
|
|
391
|
+
theme: any,
|
|
392
|
+
context: any,
|
|
393
|
+
maxLines = 15,
|
|
394
|
+
) => {
|
|
395
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
396
|
+
const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
|
|
397
|
+
if (!output) {
|
|
398
|
+
text.setText(theme.fg("muted", "No output"));
|
|
399
|
+
return text;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const lines = output.split("\n");
|
|
403
|
+
const displayLines = lines.slice(0, options.expanded ? lines.length : maxLines);
|
|
404
|
+
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
|
|
405
|
+
if (lines.length > displayLines.length) {
|
|
406
|
+
content += theme.fg(
|
|
407
|
+
"muted",
|
|
408
|
+
`\n... (${lines.length - displayLines.length} more lines)`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
text.setText(content);
|
|
412
|
+
return text;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// --- grep tool ---
|
|
416
|
+
|
|
417
|
+
const grepSchema = Type.Object({
|
|
418
|
+
pattern: Type.String({ description: "Search pattern (plain text or regex)" }),
|
|
419
|
+
path: Type.Optional(
|
|
420
|
+
Type.String({
|
|
421
|
+
description:
|
|
422
|
+
"Directory or file constraint, e.g. 'src/' or '*.ts' (default: project root)",
|
|
423
|
+
}),
|
|
424
|
+
),
|
|
425
|
+
literal: Type.Optional(
|
|
426
|
+
Type.Boolean({
|
|
427
|
+
description: "Treat pattern as literal string instead of regex (default: true)",
|
|
428
|
+
}),
|
|
429
|
+
),
|
|
430
|
+
context: Type.Optional(
|
|
431
|
+
Type.Number({
|
|
432
|
+
description: "Number of lines to show before and after each match (default: 0)",
|
|
433
|
+
}),
|
|
434
|
+
),
|
|
435
|
+
limit: Type.Optional(
|
|
436
|
+
Type.Number({
|
|
437
|
+
description: `Maximum number of matches to return (default: ${DEFAULT_GREP_LIMIT})`,
|
|
438
|
+
}),
|
|
439
|
+
),
|
|
440
|
+
cursor: Type.Optional(
|
|
441
|
+
Type.String({ description: "Cursor from previous result for pagination" }),
|
|
442
|
+
),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
pi.registerTool({
|
|
446
|
+
name: toolNames.grep,
|
|
447
|
+
label: toolNames.grep,
|
|
448
|
+
description: `Search file contents for a pattern using FFF (fast, frecency-ranked, git-aware). Returns matching lines with file paths and line numbers. Respects .gitignore. Supports plain text, regex, and fuzzy search modes. Smart case by default. Output truncated to ${DEFAULT_GREP_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB.`,
|
|
449
|
+
promptSnippet:
|
|
450
|
+
"Search file contents for patterns (FFF: frecency-ranked, git-aware, respects .gitignore)",
|
|
451
|
+
promptGuidelines: [
|
|
452
|
+
"Search for bare identifiers (e.g. 'InProgressQuote'), not code syntax or multi-token regex.",
|
|
453
|
+
"Plain text search is faster and more reliable than regex. Prefer it.",
|
|
454
|
+
"After 2 grep calls, read the top result file instead of grepping more.",
|
|
455
|
+
"Use the path parameter for file/directory constraints: '*.ts', 'src/'.",
|
|
456
|
+
],
|
|
457
|
+
parameters: grepSchema,
|
|
458
|
+
|
|
459
|
+
async execute(_toolCallId, params, signal) {
|
|
460
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
461
|
+
|
|
462
|
+
const f = await ensureFinder(activeCwd);
|
|
463
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
464
|
+
const query = params.path ? `${params.path} ${params.pattern}` : params.pattern;
|
|
465
|
+
const mode: GrepMode = params.literal === false ? "regex" : "plain";
|
|
466
|
+
|
|
467
|
+
const grepResult = f.grep(query, {
|
|
468
|
+
mode,
|
|
469
|
+
smartCase: true,
|
|
470
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
471
|
+
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
472
|
+
beforeContext: params.context ?? 0,
|
|
473
|
+
afterContext: params.context ?? 0,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
477
|
+
|
|
478
|
+
const result = grepResult.value;
|
|
479
|
+
let output = formatGrepOutput(result, effectiveLimit);
|
|
480
|
+
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
481
|
+
output = truncation.content;
|
|
482
|
+
|
|
483
|
+
const notices: string[] = [];
|
|
484
|
+
if (result.items.length >= effectiveLimit)
|
|
485
|
+
notices.push(
|
|
486
|
+
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more`,
|
|
487
|
+
);
|
|
488
|
+
if (truncation.truncated)
|
|
489
|
+
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
490
|
+
if (result.regexFallbackError)
|
|
491
|
+
notices.push(`Regex failed: ${result.regexFallbackError}, used literal match`);
|
|
492
|
+
if (result.nextCursor)
|
|
493
|
+
notices.push(
|
|
494
|
+
`More results available. Use cursor="${storeCursor(result.nextCursor)}" to continue`,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
content: [{ type: "text", text: output }],
|
|
501
|
+
details: {
|
|
502
|
+
totalMatched: result.totalMatched,
|
|
503
|
+
totalFiles: result.totalFiles,
|
|
504
|
+
truncated: truncation.truncated,
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
renderCall(args, theme, context) {
|
|
510
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
511
|
+
const pattern = args?.pattern ?? "";
|
|
512
|
+
const path = args?.path ?? ".";
|
|
513
|
+
let content =
|
|
514
|
+
theme.fg("toolTitle", theme.bold(toolNames.grep)) +
|
|
515
|
+
" " +
|
|
516
|
+
theme.fg("accent", `/${pattern}/`) +
|
|
517
|
+
theme.fg("toolOutput", ` in ${path}`);
|
|
518
|
+
if (args?.limit !== undefined)
|
|
519
|
+
content += theme.fg("toolOutput", ` limit ${args.limit}`);
|
|
520
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
521
|
+
text.setText(content);
|
|
522
|
+
return text;
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
renderResult(result, options, theme, context) {
|
|
526
|
+
return renderTextResult(result, options, theme, context, 15);
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// --- find tool ---
|
|
531
|
+
|
|
532
|
+
const findSchema = Type.Object({
|
|
533
|
+
pattern: Type.String({
|
|
534
|
+
description:
|
|
535
|
+
"Fuzzy search query for file names. Supports path prefixes ('src/') and globs ('*.ts').",
|
|
536
|
+
}),
|
|
537
|
+
path: Type.Optional(
|
|
538
|
+
Type.String({ description: "Directory to search in (default: project root)" }),
|
|
539
|
+
),
|
|
540
|
+
limit: Type.Optional(
|
|
541
|
+
Type.Number({
|
|
542
|
+
description: `Maximum number of results (default: ${DEFAULT_FIND_LIMIT})`,
|
|
543
|
+
}),
|
|
544
|
+
),
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
pi.registerTool({
|
|
548
|
+
name: toolNames.find,
|
|
549
|
+
label: toolNames.find,
|
|
550
|
+
description: `Fuzzy file search by name using FFF (fast, frecency-ranked, git-aware). Returns matching file paths relative to project root. Respects .gitignore. Supports fuzzy matching, path prefixes ('src/'), and glob constraints ('*.ts', '**/*.spec.ts'). Output truncated to ${DEFAULT_FIND_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB.`,
|
|
551
|
+
promptSnippet:
|
|
552
|
+
"Find files by name (FFF: fuzzy, frecency-ranked, git-aware, respects .gitignore)",
|
|
553
|
+
promptGuidelines: [
|
|
554
|
+
"Keep queries short -- prefer 1-2 terms max.",
|
|
555
|
+
"Multiple words narrow results (waterfall), they are not OR.",
|
|
556
|
+
"Use this to find files by name. Use grep to search file contents.",
|
|
557
|
+
],
|
|
558
|
+
parameters: findSchema,
|
|
559
|
+
|
|
560
|
+
async execute(_toolCallId, params, signal) {
|
|
561
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
562
|
+
|
|
563
|
+
const f = await ensureFinder(activeCwd);
|
|
564
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
|
|
565
|
+
const query = params.path ? `${params.path} ${params.pattern}` : params.pattern;
|
|
566
|
+
|
|
567
|
+
const searchResult = f.fileSearch(query, { pageSize: effectiveLimit });
|
|
568
|
+
if (!searchResult.ok) throw new Error(searchResult.error);
|
|
569
|
+
|
|
570
|
+
const result = searchResult.value;
|
|
571
|
+
let output = formatFindOutput(result, effectiveLimit);
|
|
572
|
+
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
573
|
+
output = truncation.content;
|
|
574
|
+
|
|
575
|
+
const notices: string[] = [];
|
|
576
|
+
if (result.items.length >= effectiveLimit)
|
|
577
|
+
notices.push(
|
|
578
|
+
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
|
579
|
+
);
|
|
580
|
+
if (truncation.truncated)
|
|
581
|
+
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
582
|
+
if (result.totalMatched > result.items.length)
|
|
583
|
+
notices.push(
|
|
584
|
+
`${result.totalMatched} total matches (${result.totalFiles} indexed files)`,
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
content: [{ type: "text", text: output }],
|
|
591
|
+
details: {
|
|
592
|
+
totalMatched: result.totalMatched,
|
|
593
|
+
totalFiles: result.totalFiles,
|
|
594
|
+
truncated: truncation.truncated,
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
renderCall(args, theme, context) {
|
|
600
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
601
|
+
const pattern = args?.pattern ?? "";
|
|
602
|
+
const path = args?.path ?? ".";
|
|
603
|
+
let content =
|
|
604
|
+
theme.fg("toolTitle", theme.bold(toolNames.find)) +
|
|
605
|
+
" " +
|
|
606
|
+
theme.fg("accent", pattern) +
|
|
607
|
+
theme.fg("toolOutput", ` in ${path}`);
|
|
608
|
+
if (args?.limit !== undefined)
|
|
609
|
+
content += theme.fg("toolOutput", ` (limit ${args.limit})`);
|
|
610
|
+
text.setText(content);
|
|
611
|
+
return text;
|
|
612
|
+
},
|
|
613
|
+
|
|
614
|
+
renderResult(result, options, theme, context) {
|
|
615
|
+
return renderTextResult(result, options, theme, context, 20);
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// --- multi_grep tool ---
|
|
620
|
+
|
|
621
|
+
const multiGrepSchema = Type.Object({
|
|
622
|
+
patterns: Type.Array(Type.String(), {
|
|
623
|
+
description:
|
|
624
|
+
"Patterns to search for (OR logic -- matches lines containing ANY pattern). Include all naming conventions: snake_case, PascalCase, camelCase.",
|
|
625
|
+
}),
|
|
626
|
+
constraints: Type.Optional(
|
|
627
|
+
Type.String({
|
|
628
|
+
description:
|
|
629
|
+
"File constraints, e.g. '*.{ts,tsx} !test/' to filter files. Separate from patterns.",
|
|
630
|
+
}),
|
|
631
|
+
),
|
|
632
|
+
context: Type.Optional(
|
|
633
|
+
Type.Number({
|
|
634
|
+
description: "Number of context lines before and after each match (default: 0)",
|
|
635
|
+
}),
|
|
636
|
+
),
|
|
637
|
+
limit: Type.Optional(
|
|
638
|
+
Type.Number({
|
|
639
|
+
description: `Maximum number of matches to return (default: ${DEFAULT_GREP_LIMIT})`,
|
|
640
|
+
}),
|
|
641
|
+
),
|
|
642
|
+
cursor: Type.Optional(
|
|
643
|
+
Type.String({ description: "Cursor from previous result for pagination" }),
|
|
644
|
+
),
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
pi.registerTool({
|
|
648
|
+
name: toolNames.multiGrep,
|
|
649
|
+
label: toolNames.multiGrep,
|
|
650
|
+
description:
|
|
651
|
+
"Search file contents for lines matching ANY of multiple patterns (OR logic). Uses SIMD-accelerated Aho-Corasick multi-pattern matching. Faster than regex alternation. Patterns are literal text -- never escape special characters. Use the constraints parameter for file filtering ('*.rs', 'src/', '!test/').",
|
|
652
|
+
promptSnippet:
|
|
653
|
+
"Multi-pattern OR search across file contents (FFF: SIMD-accelerated, frecency-ranked)",
|
|
654
|
+
promptGuidelines: [
|
|
655
|
+
`Use ${toolNames.multiGrep} when you need to find multiple identifiers at once (OR logic).`,
|
|
656
|
+
"Include all naming conventions: snake_case, PascalCase, camelCase variants.",
|
|
657
|
+
"Patterns are literal text. Never escape special characters.",
|
|
658
|
+
"Use the constraints parameter for file type/path filtering, not inside patterns.",
|
|
659
|
+
],
|
|
660
|
+
parameters: multiGrepSchema,
|
|
661
|
+
|
|
662
|
+
async execute(_toolCallId, params, signal) {
|
|
663
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
664
|
+
if (!params.patterns?.length)
|
|
665
|
+
throw new Error("patterns array must have at least 1 element");
|
|
666
|
+
|
|
667
|
+
const f = await ensureFinder(activeCwd);
|
|
668
|
+
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
669
|
+
|
|
670
|
+
const grepResult = f.multiGrep({
|
|
671
|
+
patterns: params.patterns,
|
|
672
|
+
constraints: params.constraints,
|
|
673
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
674
|
+
smartCase: true,
|
|
675
|
+
cursor: (params.cursor ? getCursor(params.cursor) : null) ?? null,
|
|
676
|
+
beforeContext: params.context ?? 0,
|
|
677
|
+
afterContext: params.context ?? 0,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
if (!grepResult.ok) throw new Error(grepResult.error);
|
|
681
|
+
|
|
682
|
+
const result = grepResult.value;
|
|
683
|
+
let output = formatGrepOutput(result, effectiveLimit);
|
|
684
|
+
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
685
|
+
output = truncation.content;
|
|
686
|
+
|
|
687
|
+
const notices: string[] = [];
|
|
688
|
+
if (result.items.length >= effectiveLimit)
|
|
689
|
+
notices.push(
|
|
690
|
+
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more`,
|
|
691
|
+
);
|
|
692
|
+
if (truncation.truncated)
|
|
693
|
+
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
694
|
+
if (result.nextCursor)
|
|
695
|
+
notices.push(
|
|
696
|
+
`More results available. Use cursor="${storeCursor(result.nextCursor)}" to continue`,
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
content: [{ type: "text", text: output }],
|
|
703
|
+
details: {
|
|
704
|
+
totalMatched: result.totalMatched,
|
|
705
|
+
totalFiles: result.totalFiles,
|
|
706
|
+
truncated: truncation.truncated,
|
|
707
|
+
patterns: params.patterns,
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
renderCall(args, theme, context) {
|
|
713
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
714
|
+
const patterns = args?.patterns ?? [];
|
|
715
|
+
const constraints = args?.constraints;
|
|
716
|
+
let content =
|
|
717
|
+
theme.fg("toolTitle", theme.bold(toolNames.multiGrep)) +
|
|
718
|
+
" " +
|
|
719
|
+
theme.fg("accent", patterns.map((p: string) => `"${p}"`).join(", "));
|
|
720
|
+
if (constraints) content += theme.fg("toolOutput", ` (${constraints})`);
|
|
721
|
+
if (args?.cursor) content += theme.fg("muted", ` (page)`);
|
|
722
|
+
text.setText(content);
|
|
723
|
+
return text;
|
|
724
|
+
},
|
|
725
|
+
|
|
726
|
+
renderResult(result, options, theme, context) {
|
|
727
|
+
return renderTextResult(result, options, theme, context, 15);
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// --- commands ---
|
|
732
|
+
|
|
733
|
+
pi.registerCommand("fff-mode", {
|
|
734
|
+
description: "Show or set FFF mode: /fff-mode [tools-and-ui | tools-only | override]",
|
|
735
|
+
handler: async (args, ctx) => {
|
|
736
|
+
const arg = (args || "").trim();
|
|
737
|
+
|
|
738
|
+
// No args - show current mode
|
|
739
|
+
if (!arg) {
|
|
740
|
+
const mode = getMode();
|
|
741
|
+
const flag = pi.getFlag("fff-mode") ?? "unset";
|
|
742
|
+
const env = process.env.PI_FFF_MODE ?? "unset";
|
|
743
|
+
ctx.ui.notify(`Current mode: '${mode}'\nFlag: ${flag}, Env: ${env}`, "info");
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Validate and set mode
|
|
748
|
+
if (!VALID_MODES.includes(arg as FffMode)) {
|
|
749
|
+
ctx.ui.notify(`Usage: /fff-mode [${VALID_MODES.join(" | ")}]`, "warning");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const newMode = arg as FffMode;
|
|
754
|
+
const oldMode = getMode();
|
|
755
|
+
setMode(newMode);
|
|
756
|
+
|
|
757
|
+
// Apply immediately using the shared function
|
|
758
|
+
applyEditorMode(ctx);
|
|
759
|
+
|
|
760
|
+
const note =
|
|
761
|
+
(oldMode === "override") !== (newMode === "override")
|
|
762
|
+
? " (tool name change requires restart)"
|
|
763
|
+
: "";
|
|
764
|
+
ctx.ui.notify(`Mode changed: '${oldMode}' → '${newMode}'${note}`, "info");
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
pi.registerCommand("fff-health", {
|
|
769
|
+
description: "Show FFF file finder health and status",
|
|
770
|
+
handler: async (_args, ctx) => {
|
|
771
|
+
if (!finder || finder.isDestroyed) {
|
|
772
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const health = finder.healthCheck();
|
|
777
|
+
if (!health.ok) {
|
|
778
|
+
ctx.ui.notify(`Health check failed: ${health.error}`, "error");
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const h = health.value;
|
|
783
|
+
const lines = [
|
|
784
|
+
`FFF v${h.version}`,
|
|
785
|
+
`Mode: ${getMode()}`,
|
|
786
|
+
`Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
|
|
787
|
+
`Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
|
788
|
+
`Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
|
|
789
|
+
`Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
|
|
790
|
+
];
|
|
791
|
+
|
|
792
|
+
const progress = finder.getScanProgress();
|
|
793
|
+
if (progress.ok) {
|
|
794
|
+
lines.push(
|
|
795
|
+
`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
pi.registerCommand("fff-rescan", {
|
|
804
|
+
description: "Trigger FFF to rescan files",
|
|
805
|
+
handler: async (_args, ctx) => {
|
|
806
|
+
if (!finder || finder.isDestroyed) {
|
|
807
|
+
ctx.ui.notify("FFF not initialized", "warning");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const result = finder.scanFiles();
|
|
812
|
+
if (!result.ok) {
|
|
813
|
+
ctx.ui.notify(`Rescan failed: ${result.error}`, "error");
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
ctx.ui.notify("FFF rescan triggered", "info");
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
}
|