Skip to content

Commit a779933

Browse files
authored
feat: optional selection only mode (#14)
* feat: introduce optional selection-only mode * fix: do not render suggestions if they're not present * docs: explain selection-only option in the readme
1 parent f8b18fd commit a779933

File tree

3 files changed

+122
-18
lines changed

3 files changed

+122
-18
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,30 @@ Becomes a list of commands:
2525
- 📚 Prompt Library: Organize prompts in a folder structure
2626
- 🔥 Instant Commands: Your prompts transform into Obsidian commands
2727

28+
## Extra Features
29+
30+
### Selection-Only Commands
31+
32+
Some prompts work best when applied to a specific selection of text. You can mark a command as selection-only by adding frontmatter to your prompt file:
33+
34+
```yaml
35+
---
36+
llm-shortcut-selection-mode: selection-only
37+
---
38+
39+
Your prompt content here...
40+
```
41+
42+
When a command is marked as selection-only, it will:
43+
44+
- Require text to be selected before execution
45+
- Show an error notification if you try to run it without a selection
46+
- Only process the selected text (and the document context) when executed
47+
48+
This is useful for prompts that are designed to transform, analyze, or modify specific portions of text rather than working with the entire document.
49+
50+
<video width="350" alt="Demo Selection Only" src="https://github.com/user-attachments/assets/4eabe88a-d4c5-4928-b357-ad0928b7484b" />
51+
2852
## Example
2953

3054
Probably the best way to explain the workflow is via this little vid I made:

src/main.ts

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ import { PLUGIN_NAME } from "./utils/constants";
2121
import { mapCursorPositionToIdx } from "./utils/obsidian/map-position-to-idx";
2222
import { obsidianFetchAdapter } from "./utils/obsidian/obsidian-fetch-adapter";
2323

24+
const SELECTION_MODE_TAG = "llm-shortcut-selection-mode";
25+
const SELECTION_ONLY_VALUE = "selection-only";
26+
27+
interface CommandOptions {
28+
readonly shouldHandleSelectionOnly?: boolean;
29+
}
30+
31+
export interface ParsedCommandPrompt {
32+
readonly content: string;
33+
readonly options?: CommandOptions;
34+
}
35+
2436
interface PluginSettings {
2537
apiKey: string;
2638
providerUrl: string;
@@ -63,6 +75,13 @@ export default class LlmShortcutPlugin extends Plugin {
6375
file.path.startsWith(this.settings.promptLibraryDirectory);
6476

6577
this.eventRefs = [
78+
this.app.metadataCache.on("changed", (file) => {
79+
if (!isPromptLibraryDirectory(file)) {
80+
return;
81+
}
82+
83+
this.recurseOverAbstractFile(file, file.parent?.path.split("/") ?? []);
84+
}),
6685
this.app.vault.on("modify", (file) => {
6786
if (!isPromptLibraryDirectory(file)) {
6887
return;
@@ -131,45 +150,104 @@ export default class LlmShortcutPlugin extends Plugin {
131150

132151
logger.debug(`Added command ${readableCommandName}`);
133152

134-
this.addCommandBasedOnPrompt(
135-
readableCommandName,
136-
file.path,
137-
await file.vault.read(file),
138-
);
153+
const { content, options } = await this.parseCommandPromptFromFile(file);
154+
155+
this.addCommandBasedOnPrompt({
156+
name: readableCommandName,
157+
path: file.path,
158+
prompt: content,
159+
options,
160+
});
139161
} else if (file instanceof TFolder) {
140162
for (const child of file.children) {
141163
this.recurseOverAbstractFile(child, currentPath);
142164
}
143165
}
144166
}
145167

146-
private addCommandBasedOnPrompt(name: string, path: string, prompt: string) {
168+
private async parseCommandPromptFromFile(
169+
file: TFile,
170+
): Promise<ParsedCommandPrompt> {
171+
const fileContent = await file.vault.read(file);
172+
// Danger! The cache could be stale (but we're listening to changes so this will be overriden next run)
173+
const metadata = this.app.metadataCache.getFileCache(file);
174+
175+
// Use Obsidian's parsed frontmatter if available
176+
if (!metadata?.frontmatter || !metadata.frontmatterPosition) {
177+
return { content: fileContent };
178+
}
179+
const shouldHandleSelectionOnly =
180+
metadata.frontmatter[SELECTION_MODE_TAG] === SELECTION_ONLY_VALUE;
181+
182+
const content = fileContent
183+
.slice(metadata.frontmatterPosition.end.offset)
184+
.trimStart();
185+
186+
return {
187+
content,
188+
options: {
189+
shouldHandleSelectionOnly,
190+
},
191+
};
192+
}
193+
194+
private addCommandBasedOnPrompt({
195+
name,
196+
path,
197+
prompt,
198+
options,
199+
}: {
200+
readonly name: string;
201+
readonly path: string;
202+
readonly prompt: string;
203+
readonly options: CommandOptions | undefined;
204+
}) {
147205
const command = {
148206
id: path,
149207
name,
150-
editorCallback: this.handleRespond.bind(this, prompt),
208+
editorCallback: this.handleRespond.bind(
209+
this,
210+
prompt,
211+
options?.shouldHandleSelectionOnly ?? false,
212+
),
151213
};
152214
this.commands.push(command);
153215
this.addCommand(command);
154216
}
155217

156-
private async handleRespond(systemPrompt: string, editor: Editor) {
218+
private async handleRespond(
219+
systemPrompt: string,
220+
requiresSelection: boolean,
221+
editor: Editor,
222+
) {
157223
assertExists(this.llmClient, "LLM client is not initialized");
158224

225+
const startIdx = mapCursorPositionToIdx(
226+
editor.getValue(),
227+
editor.getCursor("from"),
228+
);
229+
const endIdx = mapCursorPositionToIdx(
230+
editor.getValue(),
231+
editor.getCursor("to"),
232+
);
233+
234+
if (requiresSelection) {
235+
if (startIdx === endIdx) {
236+
showErrorNotification({
237+
title: "This command requires text to be selected",
238+
});
239+
return;
240+
}
241+
}
242+
159243
this.loaderStrategy.start();
160244
try {
161245
const responseStream = this.llmClient.getResponse({
162246
userPrompt: {
163247
currentContent: editor.getValue(),
164248
selection: {
165-
startIdx: mapCursorPositionToIdx(
166-
editor.getValue(),
167-
editor.getCursor("from"),
168-
),
169-
endIdx: mapCursorPositionToIdx(
170-
editor.getValue(),
171-
editor.getCursor("to"),
172-
),
249+
startIdx,
250+
endIdx,
173251
},
174252
},
175253
systemPrompt,

src/ui/user-notifications.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ export function showErrorNotification(
2020
let message = withSeparator(`🤖 ${PLUGIN_NAME} Error:`, TWO_LINE_SEP);
2121
message += withSeparator(options.title, TWO_LINE_SEP);
2222
message += withSeparator(options.message, TWO_LINE_SEP);
23-
message += withSeparator("💡 Suggestions:", TWO_LINE_SEP);
24-
message += suggestionsText;
23+
if (suggestionsText) {
24+
message += withSeparator("💡 Suggestions:", TWO_LINE_SEP);
25+
message += suggestionsText;
26+
}
2527

2628
new Notice(message, timeout);
2729
}

0 commit comments

Comments
 (0)