@zigai/pi-mention-project 0.2.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 ADDED
@@ -0,0 +1,76 @@
1
+ # Pi Mention Project
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@zigai/pi-mention-project.svg?color=blue)](https://www.npmjs.com/package/@zigai/pi-mention-project)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@zigai/pi-mention-project.svg)](https://www.npmjs.com/package/@zigai/pi-mention-project)
5
+ [![license](https://img.shields.io/npm/l/@zigai/pi-mention-project.svg)](../../LICENSE)
6
+
7
+ This Pi extension adds fuzzy project directory mentions that default to `#`.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pi install npm:@zigai/pi-mention-project
13
+ ```
14
+
15
+ ## Features
16
+
17
+ - Adds fuzzy autocomplete for project folders with `#` mentions.
18
+ - Searches only the direct child folders inside configured project roots.
19
+ - Defaults to Git repository folders and ignores dot-prefixed folders.
20
+ - Expands project mentions before the model sees the prompt so the model gets each project's absolute path.
21
+
22
+ ## Usage
23
+
24
+ Configure one or more project roots, then type `#` in the prompt editor and start typing a folder name.
25
+
26
+ If `~/Projects` contains `pi-tweaks`, selecting `#pi-tweaks` adds that project mention. Before the model sees the prompt, the extension prepends project metadata with the absolute project path and removes the `#` sigil from the visible sentence.
27
+
28
+ ## Configuration
29
+
30
+ Configuration lives in Pi settings: globally in `~/.pi/agent/settings.json`, or per trusted project in `.pi/settings.json`.
31
+
32
+ Project roots default to an empty list. Set `mentionProjectRoots` to the directories whose direct child folders should be mentionable:
33
+
34
+ ```json
35
+ {
36
+ "mentionProjectRoots": ["~/Projects", "~/Work"]
37
+ }
38
+ ```
39
+
40
+ Only immediate child directories are listed. For example, `~/Projects/app` is mentionable, but `~/Projects/app/packages/api` is not.
41
+
42
+ By default, a child folder is listed only when it has a `.git` directory or worktree `.git` file, and folders whose names start with `.` are hidden. To include non-Git folders, set `mentionProjectGitReposOnly` to `false`:
43
+
44
+ ```json
45
+ {
46
+ "mentionProjectGitReposOnly": false
47
+ }
48
+ ```
49
+
50
+ To include dot-prefixed folders, set `mentionProjectIncludeDotFolders` to `true`:
51
+
52
+ ```json
53
+ {
54
+ "mentionProjectIncludeDotFolders": true
55
+ }
56
+ ```
57
+
58
+ For one-off runs, the same filters can be relaxed with CLI flags:
59
+
60
+ ```sh
61
+ pi --mention-project-include-non-git --mention-project-include-dot-folders
62
+ ```
63
+
64
+ The mention character defaults to `#`. To change it, set `mentionProjectTrigger` to a single non-whitespace character:
65
+
66
+ ```json
67
+ {
68
+ "mentionProjectTrigger": "@"
69
+ }
70
+ ```
71
+
72
+ Folder names should be unique across configured roots. If the same folder name exists in multiple roots, the first configured root wins.
73
+
74
+ ## License
75
+
76
+ MIT
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@zigai/pi-mention-project",
3
+ "version": "0.2.0",
4
+ "description": "Pi package that adds fuzzy project directory mentions.",
5
+ "keywords": [
6
+ "mention",
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "pi-extension",
10
+ "pi-package",
11
+ "pi-tweaks",
12
+ "project"
13
+ ],
14
+ "homepage": "https://github.com/zigai/pi-tweaks/tree/main/packages/pi-mention-project#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/zigai/pi-tweaks/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "zigai",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/zigai/pi-tweaks.git",
23
+ "directory": "packages/pi-mention-project"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "type": "module",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "typebox": "^1.1.38"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./src/index.ts"
43
+ ]
44
+ }
45
+ }
@@ -0,0 +1,111 @@
1
+ import {
2
+ type AutocompleteItem,
3
+ type AutocompleteProvider,
4
+ type AutocompleteSuggestions,
5
+ fuzzyFilter,
6
+ } from "@earendil-works/pi-tui";
7
+
8
+ import { DEFAULT_MENTION_TRIGGER } from "./settings.ts";
9
+ import { extractProjectMentionPrefix, formatProjectMention } from "./mention-syntax.ts";
10
+ import type { MentionProjectSettings, ProjectDirectory } from "./types.ts";
11
+
12
+ const MAX_SUGGESTIONS = 20;
13
+
14
+ type ProjectLoader = () => Promise<ProjectDirectory[]>;
15
+
16
+ function projectToItem(
17
+ project: ProjectDirectory,
18
+ trigger = DEFAULT_MENTION_TRIGGER,
19
+ ): AutocompleteItem {
20
+ const value = formatProjectMention(project.name, trigger);
21
+ return {
22
+ value,
23
+ label: project.name,
24
+ description: project.path,
25
+ };
26
+ }
27
+
28
+ function filterProjects(
29
+ projects: ProjectDirectory[],
30
+ query: string,
31
+ trigger: string,
32
+ ): AutocompleteItem[] {
33
+ if (query.length === 0) {
34
+ return projects.slice(0, MAX_SUGGESTIONS).map((project) => projectToItem(project, trigger));
35
+ }
36
+
37
+ return fuzzyFilter(projects, query, (project) => `${project.name} ${project.path}`)
38
+ .slice(0, MAX_SUGGESTIONS)
39
+ .map((project) => projectToItem(project, trigger));
40
+ }
41
+
42
+ export function createProjectMentionProvider(
43
+ current: AutocompleteProvider,
44
+ settings: MentionProjectSettings,
45
+ loadProjects: ProjectLoader,
46
+ ): AutocompleteProvider {
47
+ const { trigger } = settings;
48
+
49
+ const provider = {
50
+ triggerCharacters: [trigger],
51
+
52
+ async getSuggestions(
53
+ lines: string[],
54
+ cursorLine: number,
55
+ cursorCol: number,
56
+ options: { signal: AbortSignal; force?: boolean },
57
+ ): Promise<AutocompleteSuggestions | null> {
58
+ const line = lines[cursorLine] ?? "";
59
+ const beforeCursor = line.slice(0, cursorCol);
60
+ const mention = extractProjectMentionPrefix(beforeCursor, trigger);
61
+ if (mention === undefined) {
62
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
63
+ }
64
+
65
+ const projects = await loadProjects();
66
+ if (options.signal.aborted || projects.length === 0) {
67
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
68
+ }
69
+
70
+ const items = filterProjects(projects, mention.query, trigger);
71
+ if (items.length === 0) {
72
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
73
+ }
74
+ return { prefix: mention.prefix, items };
75
+ },
76
+
77
+ applyCompletion(
78
+ lines: string[],
79
+ cursorLine: number,
80
+ cursorCol: number,
81
+ item: AutocompleteItem,
82
+ prefix: string,
83
+ ) {
84
+ if (!prefix.startsWith(trigger)) {
85
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
86
+ }
87
+
88
+ const currentLine = lines[cursorLine] ?? "";
89
+ const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
90
+ const afterCursor = currentLine.slice(cursorCol);
91
+ const needsSpace = afterCursor.length === 0 || !/^\s/.test(afterCursor);
92
+ let suffix = "";
93
+ if (needsSpace) {
94
+ suffix = " ";
95
+ }
96
+ const newLines = [...lines];
97
+ newLines[cursorLine] = `${beforePrefix}${item.value}${suffix}${afterCursor}`;
98
+ return {
99
+ lines: newLines,
100
+ cursorLine,
101
+ cursorCol: beforePrefix.length + item.value.length + suffix.length,
102
+ };
103
+ },
104
+
105
+ shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number) {
106
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
107
+ },
108
+ };
109
+
110
+ return provider;
111
+ }
package/src/editor.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { CustomEditor, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ autocompleteStartIndex,
5
+ colorProjectMentions,
6
+ isProjectMentionContext,
7
+ } from "./rendering.ts";
8
+ import type { EditorFactory, EditorLike, ProjectDirectory } from "./types.ts";
9
+
10
+ const MENTION_FACTORY_BASE = Symbol.for("zigai.pi-mention-project.editor-factory-base");
11
+
12
+ type WrappedEditorFactory = EditorFactory & {
13
+ [MENTION_FACTORY_BASE]?: EditorFactory | undefined;
14
+ };
15
+
16
+ type ProjectSnapshot = () => ProjectDirectory[];
17
+
18
+ function shouldReactToInput(data: string, trigger: string): boolean {
19
+ if (data === trigger) return true;
20
+ if (data.length !== 1) return false;
21
+ return !/\s/.test(data);
22
+ }
23
+
24
+ function enhanceEditor(
25
+ editor: EditorLike,
26
+ ctx: ExtensionContext,
27
+ trigger: string,
28
+ getProjects: ProjectSnapshot,
29
+ ): EditorLike {
30
+ const originalHandleInput = editor.handleInput.bind(editor);
31
+ editor.handleInput = (data: string) => {
32
+ originalHandleInput(data);
33
+
34
+ if (!shouldReactToInput(data, trigger)) return;
35
+
36
+ const text = editor.getText();
37
+ const lines = text.split("\n");
38
+ const lastLine = lines[lines.length - 1];
39
+ let currentLine = "";
40
+ if (lastLine !== undefined) {
41
+ currentLine = lastLine;
42
+ }
43
+ if (!isProjectMentionContext(currentLine, trigger)) return;
44
+ if (editor.isShowingAutocomplete?.() === true) return;
45
+ editor.tryTriggerAutocomplete?.();
46
+ };
47
+
48
+ const originalRender = editor.render.bind(editor);
49
+ editor.render = (width: number) => {
50
+ const renderedLines = originalRender(width);
51
+ let colorThrough = renderedLines.length;
52
+ if (editor.isShowingAutocomplete?.() === true) {
53
+ colorThrough = autocompleteStartIndex(renderedLines);
54
+ }
55
+ return renderedLines.map((line, index) => {
56
+ if (index >= colorThrough) return line;
57
+ return colorProjectMentions(line, ctx, trigger, getProjects());
58
+ });
59
+ };
60
+
61
+ return editor;
62
+ }
63
+
64
+ export function applyMentionProjectEditor(
65
+ ctx: ExtensionContext,
66
+ trigger: string,
67
+ getProjects: ProjectSnapshot,
68
+ ): void {
69
+ if (!ctx.hasUI) return;
70
+
71
+ const existing = ctx.ui.getEditorComponent() as WrappedEditorFactory | undefined;
72
+ const baseFactory = existing?.[MENTION_FACTORY_BASE] ?? existing;
73
+ const factory = ((tui, theme, keybindings) => {
74
+ const editor = (baseFactory?.(tui, theme, keybindings) ??
75
+ new CustomEditor(tui, theme, keybindings)) as unknown as EditorLike;
76
+ return enhanceEditor(editor, ctx, trigger, getProjects);
77
+ }) as WrappedEditorFactory;
78
+ factory[MENTION_FACTORY_BASE] = baseFactory;
79
+
80
+ ctx.ui.setEditorComponent(factory);
81
+ }
@@ -0,0 +1,180 @@
1
+ import type { ContextEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ parseProjectMentionName,
5
+ projectMentionPattern,
6
+ projectNameSet,
7
+ } from "./mention-syntax.ts";
8
+ import type { ProjectDirectory } from "./types.ts";
9
+
10
+ function escapeXmlText(value: string): string {
11
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
12
+ }
13
+
14
+ function escapeXmlAttribute(value: string): string {
15
+ return escapeXmlText(value).replace(/"/g, "&quot;");
16
+ }
17
+
18
+ function formatProjectBlock(project: ProjectDirectory): string {
19
+ const name = escapeXmlAttribute(project.name);
20
+ const projectPath = escapeXmlAttribute(project.path);
21
+ const root = escapeXmlAttribute(project.root);
22
+ return `<project name="${name}" path="${projectPath}" root="${root}">\nDirectory: ${escapeXmlText(project.path)}\n</project>`;
23
+ }
24
+
25
+ function formatCombinedProjectBlock(projects: ProjectDirectory[]): string {
26
+ if (projects.length === 1) {
27
+ const project = projects[0];
28
+ if (project !== undefined) return formatProjectBlock(project);
29
+ }
30
+
31
+ const content = projects.map((project) => formatProjectBlock(project)).join("\n\n");
32
+ return `<projects>\n${content}\n</projects>`;
33
+ }
34
+
35
+ function projectMap(projects: ProjectDirectory[]): Map<string, ProjectDirectory> {
36
+ const byName = new Map<string, ProjectDirectory>();
37
+ for (const project of projects) {
38
+ if (byName.has(project.name)) continue;
39
+ byName.set(project.name, project);
40
+ }
41
+ return byName;
42
+ }
43
+
44
+ function mentionedProjectNames(
45
+ text: string,
46
+ projects: ProjectDirectory[],
47
+ trigger: string,
48
+ ): Set<string> {
49
+ const names = new Set<string>();
50
+ const knownNames = projectNameSet(projects);
51
+
52
+ for (const match of text.matchAll(projectMentionPattern(trigger))) {
53
+ const parsed = parseProjectMentionName(match[2], match[3], knownNames);
54
+ if (parsed !== undefined) {
55
+ names.add(parsed.name);
56
+ }
57
+ }
58
+
59
+ return names;
60
+ }
61
+
62
+ function removeProjectMentionSigils(
63
+ text: string,
64
+ projects: ProjectDirectory[],
65
+ trigger: string,
66
+ ): string {
67
+ const knownNames = projectNameSet(projects);
68
+ return text
69
+ .replace(
70
+ projectMentionPattern(trigger),
71
+ (
72
+ match: string,
73
+ leading: string,
74
+ quotedName: string | undefined,
75
+ unquotedName: string | undefined,
76
+ ) => {
77
+ const parsed = parseProjectMentionName(quotedName, unquotedName, knownNames);
78
+ if (parsed === undefined) return match;
79
+ return `${leading}${parsed.name}${parsed.suffix}`;
80
+ },
81
+ )
82
+ .trim();
83
+ }
84
+
85
+ export function expandProjectMentions(
86
+ text: string,
87
+ projects: ProjectDirectory[],
88
+ trigger: string,
89
+ ): string {
90
+ const byName = projectMap(projects);
91
+ const names = mentionedProjectNames(text, projects, trigger);
92
+ if (names.size === 0) return text;
93
+
94
+ const loaded: ProjectDirectory[] = [];
95
+ for (const name of names) {
96
+ const project = byName.get(name);
97
+ if (project !== undefined) {
98
+ loaded.push(project);
99
+ }
100
+ }
101
+ if (loaded.length === 0) return text;
102
+
103
+ const projectBlock = formatCombinedProjectBlock(loaded);
104
+ const userMessage = removeProjectMentionSigils(text, projects, trigger);
105
+ if (userMessage.length === 0) return projectBlock;
106
+ return `${projectBlock}\n\n${userMessage}`;
107
+ }
108
+
109
+ type ContextMessage = ContextEvent["messages"][number];
110
+ type UserContextMessage = Extract<ContextMessage, { role: "user" }>;
111
+ type UserContentBlock = Exclude<UserContextMessage["content"], string>[number];
112
+ type UserTextContentBlock = Extract<UserContentBlock, { type: "text" }>;
113
+
114
+ function isUserTextContentBlock(block: UserContentBlock): block is UserTextContentBlock {
115
+ return block.type === "text";
116
+ }
117
+
118
+ function shouldExpandContextText(text: string, trigger: string): boolean {
119
+ if (!text.includes(trigger)) return false;
120
+ const trimmed = text.trimStart();
121
+ if (trimmed.startsWith("<project ")) return false;
122
+ return !trimmed.startsWith("<projects>");
123
+ }
124
+
125
+ function expandProjectMentionsInUserMessage(
126
+ message: UserContextMessage,
127
+ projects: ProjectDirectory[],
128
+ trigger: string,
129
+ ): UserContextMessage {
130
+ if (typeof message.content === "string") {
131
+ if (!shouldExpandContextText(message.content, trigger)) return message;
132
+ const expanded = expandProjectMentions(message.content, projects, trigger);
133
+ if (expanded === message.content) return message;
134
+ return { ...message, content: expanded };
135
+ }
136
+
137
+ let changed = false;
138
+ const content: UserContentBlock[] = [];
139
+ for (const block of message.content) {
140
+ if (!isUserTextContentBlock(block) || !shouldExpandContextText(block.text, trigger)) {
141
+ content.push(block);
142
+ continue;
143
+ }
144
+
145
+ const expanded = expandProjectMentions(block.text, projects, trigger);
146
+ if (expanded === block.text) {
147
+ content.push(block);
148
+ continue;
149
+ }
150
+
151
+ changed = true;
152
+ content.push({ ...block, text: expanded });
153
+ }
154
+
155
+ if (!changed) return message;
156
+ return { ...message, content };
157
+ }
158
+
159
+ export function expandProjectMentionsInMessages(
160
+ messages: ContextEvent["messages"],
161
+ projects: ProjectDirectory[],
162
+ trigger: string,
163
+ ): ContextEvent["messages"] {
164
+ let changed = false;
165
+ const expandedMessages: ContextEvent["messages"] = [];
166
+
167
+ for (const message of messages) {
168
+ if (message.role !== "user") {
169
+ expandedMessages.push(message);
170
+ continue;
171
+ }
172
+
173
+ const expanded = expandProjectMentionsInUserMessage(message, projects, trigger);
174
+ if (expanded !== message) changed = true;
175
+ expandedMessages.push(expanded);
176
+ }
177
+
178
+ if (!changed) return messages;
179
+ return expandedMessages;
180
+ }
package/src/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { createProjectMentionProvider } from "./autocomplete.ts";
4
+ import { applyMentionProjectEditor } from "./editor.ts";
5
+ import { expandProjectMentions, expandProjectMentionsInMessages } from "./expand-mentions.ts";
6
+ import { createProjectDirectorySource, listProjectDirectories } from "./projects.ts";
7
+ import {
8
+ applyMentionProjectCliFlags,
9
+ configuredMentionProjectSettings,
10
+ INCLUDE_DOT_FOLDERS_FLAG,
11
+ INCLUDE_NON_GIT_FLAG,
12
+ } from "./settings.ts";
13
+ import type { MentionProjectSettings } from "./types.ts";
14
+
15
+ function mentionProjectSettings(pi: ExtensionAPI, ctx: ExtensionContext): MentionProjectSettings {
16
+ return applyMentionProjectCliFlags(configuredMentionProjectSettings(ctx), {
17
+ includeNonGit: pi.getFlag(INCLUDE_NON_GIT_FLAG),
18
+ includeDotFolders: pi.getFlag(INCLUDE_DOT_FOLDERS_FLAG),
19
+ });
20
+ }
21
+
22
+ export default function (pi: ExtensionAPI): void {
23
+ pi.registerFlag(INCLUDE_NON_GIT_FLAG, {
24
+ description: "Include non-Git child folders in pi-mention-project suggestions.",
25
+ type: "boolean",
26
+ default: false,
27
+ });
28
+ pi.registerFlag(INCLUDE_DOT_FOLDERS_FLAG, {
29
+ description: "Include dot-prefixed child folders in pi-mention-project suggestions.",
30
+ type: "boolean",
31
+ default: false,
32
+ });
33
+
34
+ pi.on("session_start", async (_event, ctx) => {
35
+ if (!ctx.hasUI) return;
36
+ const settings = mentionProjectSettings(pi, ctx);
37
+ const projectSource = createProjectDirectorySource(settings, ctx.cwd);
38
+ void projectSource.refresh();
39
+
40
+ applyMentionProjectEditor(ctx, settings.trigger, () => projectSource.getCachedProjects());
41
+ ctx.ui.addAutocompleteProvider((current) =>
42
+ createProjectMentionProvider(current, settings, () => projectSource.getProjects()),
43
+ );
44
+ });
45
+
46
+ pi.on("input", async (event, ctx) => {
47
+ const settings = mentionProjectSettings(pi, ctx);
48
+ if (!event.text.includes(settings.trigger)) return { action: "continue" };
49
+
50
+ if (event.streamingBehavior !== undefined) {
51
+ return { action: "continue" };
52
+ }
53
+
54
+ const projects = await listProjectDirectories(settings, ctx.cwd);
55
+ const expanded = expandProjectMentions(event.text, projects, settings.trigger);
56
+ if (expanded === event.text) return { action: "continue" };
57
+ return { action: "transform", text: expanded, images: event.images };
58
+ });
59
+
60
+ pi.on("context", async (event, ctx) => {
61
+ const settings = mentionProjectSettings(pi, ctx);
62
+ const projects = await listProjectDirectories(settings, ctx.cwd);
63
+ const messages = expandProjectMentionsInMessages(
64
+ event.messages,
65
+ projects,
66
+ settings.trigger,
67
+ );
68
+ if (messages === event.messages) return;
69
+ return { messages };
70
+ });
71
+ }
@@ -0,0 +1,103 @@
1
+ import type { ProjectDirectory } from "./types.ts";
2
+ import { escapeRegExp } from "./util.ts";
3
+
4
+ const TRAILING_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", ")", "}", "]"]);
5
+
6
+ export type ParsedProjectMention = {
7
+ name: string;
8
+ suffix: string;
9
+ };
10
+
11
+ export type ProjectMentionPrefix = {
12
+ prefix: string;
13
+ query: string;
14
+ };
15
+
16
+ export function projectMentionPattern(trigger: string): RegExp {
17
+ return new RegExp(`(^|\\s)${escapeRegExp(trigger)}(?:"((?:\\\\.|[^"\\\\])*)"|([^\\s]+))`, "g");
18
+ }
19
+
20
+ function unescapeQuotedName(value: string): string {
21
+ return value.replace(/\\(["\\])/g, "$1");
22
+ }
23
+
24
+ function escapeQuotedName(value: string): string {
25
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
26
+ }
27
+
28
+ function isBareProjectName(name: string, trigger: string): boolean {
29
+ if (name.length === 0) return false;
30
+ if (name.includes(trigger)) return false;
31
+ return /^[^\s"'/]+$/.test(name);
32
+ }
33
+
34
+ export function formatProjectMention(name: string, trigger: string): string {
35
+ if (isBareProjectName(name, trigger)) return `${trigger}${name}`;
36
+ return `${trigger}"${escapeQuotedName(name)}"`;
37
+ }
38
+
39
+ function parseUnquotedName(
40
+ rawName: string,
41
+ knownNames: Set<string>,
42
+ ): ParsedProjectMention | undefined {
43
+ if (knownNames.has(rawName)) {
44
+ return { name: rawName, suffix: "" };
45
+ }
46
+
47
+ let end = rawName.length;
48
+ while (end > 0) {
49
+ const last = rawName[end - 1];
50
+ if (last === undefined || !TRAILING_PUNCTUATION.has(last)) break;
51
+ end -= 1;
52
+ const candidate = rawName.slice(0, end);
53
+ if (knownNames.has(candidate)) {
54
+ return { name: candidate, suffix: rawName.slice(end) };
55
+ }
56
+ }
57
+
58
+ return undefined;
59
+ }
60
+
61
+ export function parseProjectMentionName(
62
+ quotedName: string | undefined,
63
+ unquotedName: string | undefined,
64
+ knownNames: Set<string>,
65
+ ): ParsedProjectMention | undefined {
66
+ if (quotedName !== undefined) {
67
+ const name = unescapeQuotedName(quotedName);
68
+ if (!knownNames.has(name)) return undefined;
69
+ return { name, suffix: "" };
70
+ }
71
+
72
+ if (unquotedName === undefined) return undefined;
73
+ return parseUnquotedName(unquotedName, knownNames);
74
+ }
75
+
76
+ export function projectNameSet(projects: ProjectDirectory[]): Set<string> {
77
+ return new Set(projects.map((project) => project.name));
78
+ }
79
+
80
+ export function extractProjectMentionPrefix(
81
+ textBeforeCursor: string,
82
+ trigger: string,
83
+ ): ProjectMentionPrefix | undefined {
84
+ const escapedTrigger = escapeRegExp(trigger);
85
+ const quotedMatch = new RegExp(`(?:^|\\s)(${escapedTrigger}"([^"]*)$)`).exec(textBeforeCursor);
86
+ if (quotedMatch?.[1] !== undefined && quotedMatch[2] !== undefined) {
87
+ return { prefix: quotedMatch[1], query: quotedMatch[2] };
88
+ }
89
+
90
+ const match = new RegExp(`(?:^|\\s)(${escapedTrigger}([^\\s"]*)$)`).exec(textBeforeCursor);
91
+ if (match?.[1] !== undefined && match[2] !== undefined) {
92
+ return { prefix: match[1], query: match[2] };
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ export function extractProjectToken(textBeforeCursor: string, trigger: string): string | undefined {
98
+ return extractProjectMentionPrefix(textBeforeCursor, trigger)?.query;
99
+ }
100
+
101
+ export function isProjectMentionContext(text: string, trigger: string): boolean {
102
+ return extractProjectMentionPrefix(text, trigger) !== undefined;
103
+ }
@@ -0,0 +1,141 @@
1
+ import type { Dirent } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import type { MentionProjectSettings, ProjectDirectory } from "./types.ts";
7
+ import { compareProjectNames } from "./util.ts";
8
+
9
+ export type ProjectDirectorySource = {
10
+ getCachedProjects(): ProjectDirectory[];
11
+ getProjects(): Promise<ProjectDirectory[]>;
12
+ refresh(): Promise<ProjectDirectory[]>;
13
+ };
14
+
15
+ function expandHome(root: string): string {
16
+ if (root === "~") return os.homedir();
17
+ if (root.startsWith("~/") || root.startsWith("~\\")) {
18
+ return path.join(os.homedir(), root.slice(2));
19
+ }
20
+ return root;
21
+ }
22
+
23
+ export function resolveProjectRoot(root: string, cwd: string): string {
24
+ const expanded = expandHome(root.trim());
25
+ if (path.isAbsolute(expanded)) return path.resolve(expanded);
26
+ return path.resolve(cwd, expanded);
27
+ }
28
+
29
+ function uniqueResolvedRoots(roots: string[], cwd: string): string[] {
30
+ const seen = new Set<string>();
31
+ const resolved: string[] = [];
32
+
33
+ for (const root of roots) {
34
+ const directory = resolveProjectRoot(root, cwd);
35
+ if (seen.has(directory)) continue;
36
+ seen.add(directory);
37
+ resolved.push(directory);
38
+ }
39
+
40
+ return resolved;
41
+ }
42
+
43
+ async function directoryEntryIsDirectory(root: string, entry: Dirent): Promise<boolean> {
44
+ if (entry.isDirectory()) return true;
45
+ if (!entry.isSymbolicLink()) return false;
46
+
47
+ try {
48
+ const stats = await fs.stat(path.join(root, entry.name));
49
+ return stats.isDirectory();
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function isGitRepository(projectPath: string): Promise<boolean> {
56
+ try {
57
+ const stats = await fs.stat(path.join(projectPath, ".git"));
58
+ return stats.isDirectory() || stats.isFile();
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ async function directoryEntryMatchesSettings(
65
+ root: string,
66
+ entry: Dirent,
67
+ settings: MentionProjectSettings,
68
+ ): Promise<boolean> {
69
+ if (!settings.includeDotFolders && entry.name.startsWith(".")) return false;
70
+ if (!(await directoryEntryIsDirectory(root, entry))) return false;
71
+ if (!settings.gitReposOnly) return true;
72
+ return isGitRepository(path.join(root, entry.name));
73
+ }
74
+
75
+ async function listRootProjectDirectories(
76
+ root: string,
77
+ settings: MentionProjectSettings,
78
+ ): Promise<ProjectDirectory[]> {
79
+ let entries: Dirent[];
80
+ try {
81
+ entries = await fs.readdir(root, { withFileTypes: true });
82
+ } catch {
83
+ return [];
84
+ }
85
+
86
+ const projects: ProjectDirectory[] = [];
87
+ for (const entry of entries) {
88
+ if (!(await directoryEntryMatchesSettings(root, entry, settings))) continue;
89
+ const projectPath = path.join(root, entry.name);
90
+ projects.push({ name: entry.name, path: projectPath, root });
91
+ }
92
+
93
+ projects.sort((left, right) => compareProjectNames(left.name, right.name));
94
+ return projects;
95
+ }
96
+
97
+ export function uniqueProjectsByName(projects: ProjectDirectory[]): ProjectDirectory[] {
98
+ const seen = new Set<string>();
99
+ const unique: ProjectDirectory[] = [];
100
+
101
+ for (const project of projects) {
102
+ if (seen.has(project.name)) continue;
103
+ seen.add(project.name);
104
+ unique.push(project);
105
+ }
106
+
107
+ return unique;
108
+ }
109
+
110
+ export async function listProjectDirectories(
111
+ settings: MentionProjectSettings,
112
+ cwd: string,
113
+ ): Promise<ProjectDirectory[]> {
114
+ const projects: ProjectDirectory[] = [];
115
+ for (const root of uniqueResolvedRoots(settings.roots, cwd)) {
116
+ projects.push(...(await listRootProjectDirectories(root, settings)));
117
+ }
118
+ return uniqueProjectsByName(projects);
119
+ }
120
+
121
+ export function createProjectDirectorySource(
122
+ settings: MentionProjectSettings,
123
+ cwd: string,
124
+ ): ProjectDirectorySource {
125
+ let cachedProjects: ProjectDirectory[] = [];
126
+
127
+ const refresh = async (): Promise<ProjectDirectory[]> => {
128
+ cachedProjects = await listProjectDirectories(settings, cwd);
129
+ return [...cachedProjects];
130
+ };
131
+
132
+ return {
133
+ getCachedProjects() {
134
+ return [...cachedProjects];
135
+ },
136
+ getProjects() {
137
+ return refresh();
138
+ },
139
+ refresh,
140
+ };
141
+ }
@@ -0,0 +1,55 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ isProjectMentionContext,
5
+ parseProjectMentionName,
6
+ projectMentionPattern,
7
+ projectNameSet,
8
+ } from "./mention-syntax.ts";
9
+ import type { ProjectDirectory } from "./types.ts";
10
+
11
+ export { isProjectMentionContext };
12
+
13
+ export function colorProjectMentions(
14
+ line: string,
15
+ ctx: ExtensionContext,
16
+ trigger: string,
17
+ projects: ProjectDirectory[],
18
+ ): string {
19
+ const knownNames = projectNameSet(projects);
20
+ if (knownNames.size === 0 || !line.includes(trigger)) return line;
21
+
22
+ return line.replace(
23
+ projectMentionPattern(trigger),
24
+ (
25
+ match: string,
26
+ leading: string,
27
+ quotedName: string | undefined,
28
+ unquotedName: string | undefined,
29
+ ) => {
30
+ const parsed = parseProjectMentionName(quotedName, unquotedName, knownNames);
31
+ if (parsed === undefined) return match;
32
+
33
+ const mentionEnd = match.length - parsed.suffix.length;
34
+ const mentionText = match.slice(leading.length, mentionEnd);
35
+ return `${leading}${ctx.ui.theme.fg("accent", mentionText)}${parsed.suffix}`;
36
+ },
37
+ );
38
+ }
39
+
40
+ const ANSI_ESCAPE_PATTERN = new RegExp(
41
+ `${String.fromCharCode(27)}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])`,
42
+ "g",
43
+ );
44
+
45
+ function stripAnsi(value: string): string {
46
+ return value.replace(ANSI_ESCAPE_PATTERN, "");
47
+ }
48
+
49
+ export function autocompleteStartIndex(renderedLines: string[]): number {
50
+ for (let index = renderedLines.length - 1; index >= 0; index -= 1) {
51
+ const line = renderedLines[index];
52
+ if (line !== undefined && stripAnsi(line).startsWith("─")) return index + 1;
53
+ }
54
+ return renderedLines.length;
55
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ getAgentDir,
3
+ SettingsManager,
4
+ type ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Type, type TSchema } from "typebox";
7
+ import { Value } from "typebox/value";
8
+
9
+ import type { MentionProjectSettings } from "./types.ts";
10
+
11
+ export const DEFAULT_MENTION_TRIGGER = "#";
12
+ export const INCLUDE_NON_GIT_FLAG = "mention-project-include-non-git";
13
+ export const INCLUDE_DOT_FOLDERS_FLAG = "mention-project-include-dot-folders";
14
+
15
+ const TRIGGER_SETTINGS_KEY = "mentionProjectTrigger";
16
+ const ROOTS_SETTINGS_KEY = "mentionProjectRoots";
17
+ const GIT_REPOS_ONLY_SETTINGS_KEY = "mentionProjectGitReposOnly";
18
+ const INCLUDE_DOT_FOLDERS_SETTINGS_KEY = "mentionProjectIncludeDotFolders";
19
+
20
+ const MentionTriggerSchema = Type.String({ minLength: 1, maxLength: 1, pattern: "^[^/\\s]$" });
21
+ const ProjectRootsSchema = Type.Array(Type.String({ minLength: 1 }));
22
+ const BooleanSchema = Type.Boolean();
23
+
24
+ type ProjectTrustContext = ExtensionContext & {
25
+ isProjectTrusted?: () => boolean;
26
+ };
27
+
28
+ function isProjectTrusted(ctx: ExtensionContext): boolean {
29
+ return (ctx as ProjectTrustContext).isProjectTrusted?.() ?? true;
30
+ }
31
+
32
+ function parseOptionalString(schema: TSchema, value: unknown): string | undefined {
33
+ if (value === undefined) return undefined;
34
+ if (!Value.Check(schema, value)) return undefined;
35
+ const parsed: unknown = Value.Parse(schema, value);
36
+ if (typeof parsed === "string") return parsed;
37
+ return undefined;
38
+ }
39
+
40
+ function parseOptionalBoolean(schema: TSchema, value: unknown): boolean | undefined {
41
+ if (value === undefined) return undefined;
42
+ if (!Value.Check(schema, value)) return undefined;
43
+ const parsed: unknown = Value.Parse(schema, value);
44
+ if (typeof parsed === "boolean") return parsed;
45
+ return undefined;
46
+ }
47
+
48
+ function parseOptionalRoots(value: unknown): string[] | undefined {
49
+ if (value === undefined) return undefined;
50
+
51
+ if (typeof value === "string") {
52
+ const trimmed = value.trim();
53
+ if (trimmed.length === 0) return undefined;
54
+ return [trimmed];
55
+ }
56
+
57
+ if (!Value.Check(ProjectRootsSchema, value)) return undefined;
58
+ const parsed: unknown = Value.Parse(ProjectRootsSchema, value);
59
+ if (!Array.isArray(parsed)) return undefined;
60
+
61
+ const roots = parsed.filter((entry): entry is string => {
62
+ return typeof entry === "string" && entry.trim().length > 0;
63
+ });
64
+ if (roots.length === 0 && parsed.length > 0) return undefined;
65
+ return roots;
66
+ }
67
+
68
+ function applyMentionProjectSettings(
69
+ settings: Record<string, unknown>,
70
+ target: MentionProjectSettings,
71
+ ): void {
72
+ const trigger = parseOptionalString(MentionTriggerSchema, settings[TRIGGER_SETTINGS_KEY]);
73
+ if (trigger !== undefined) {
74
+ target.trigger = trigger;
75
+ }
76
+
77
+ const roots = parseOptionalRoots(settings[ROOTS_SETTINGS_KEY]);
78
+ if (roots !== undefined) {
79
+ target.roots = roots;
80
+ }
81
+
82
+ const gitReposOnly = parseOptionalBoolean(BooleanSchema, settings[GIT_REPOS_ONLY_SETTINGS_KEY]);
83
+ if (gitReposOnly !== undefined) {
84
+ target.gitReposOnly = gitReposOnly;
85
+ }
86
+
87
+ const includeDotFolders = parseOptionalBoolean(
88
+ BooleanSchema,
89
+ settings[INCLUDE_DOT_FOLDERS_SETTINGS_KEY],
90
+ );
91
+ if (includeDotFolders !== undefined) {
92
+ target.includeDotFolders = includeDotFolders;
93
+ }
94
+ }
95
+
96
+ export function configuredMentionProjectSettings(ctx: ExtensionContext): MentionProjectSettings {
97
+ const loaded: MentionProjectSettings = {
98
+ trigger: DEFAULT_MENTION_TRIGGER,
99
+ roots: [],
100
+ gitReposOnly: true,
101
+ includeDotFolders: false,
102
+ };
103
+
104
+ const manager = SettingsManager.create(ctx.cwd, getAgentDir(), {
105
+ projectTrusted: isProjectTrusted(ctx),
106
+ });
107
+ applyMentionProjectSettings(manager.getGlobalSettings() as Record<string, unknown>, loaded);
108
+ applyMentionProjectSettings(manager.getProjectSettings() as Record<string, unknown>, loaded);
109
+ return loaded;
110
+ }
111
+
112
+ export function applyMentionProjectCliFlags(
113
+ settings: MentionProjectSettings,
114
+ flags: { includeNonGit: unknown; includeDotFolders: unknown },
115
+ ): MentionProjectSettings {
116
+ const loaded = { ...settings };
117
+ if (flags.includeNonGit === true) {
118
+ loaded.gitReposOnly = false;
119
+ }
120
+ if (flags.includeDotFolders === true) {
121
+ loaded.includeDotFolders = true;
122
+ }
123
+ return loaded;
124
+ }
package/src/types.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
4
+
5
+ export type EditorLike = {
6
+ getText(): string;
7
+ handleInput(data: string): void;
8
+ render(width: number): string[];
9
+ isShowingAutocomplete?(): boolean;
10
+ tryTriggerAutocomplete?(explicitTab?: boolean): void;
11
+ };
12
+
13
+ export type ProjectDirectory = {
14
+ name: string;
15
+ path: string;
16
+ root: string;
17
+ };
18
+
19
+ export type MentionProjectSettings = {
20
+ trigger: string;
21
+ roots: string[];
22
+ gitReposOnly: boolean;
23
+ includeDotFolders: boolean;
24
+ };
package/src/util.ts ADDED
@@ -0,0 +1,7 @@
1
+ export function escapeRegExp(value: string): string {
2
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3
+ }
4
+
5
+ export function compareProjectNames(left: string, right: string): number {
6
+ return left.localeCompare(right, undefined, { sensitivity: "base" });
7
+ }