diff --git a/.gitignore b/.gitignore index f4cf7c2cc..f7a64ba87 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,4 @@ dev .claude/ .codex/ +docs/superpowers/ diff --git a/src/core/runtime/types.ts b/src/core/runtime/types.ts index 3bfbcc8f3..4c7f0244b 100644 --- a/src/core/runtime/types.ts +++ b/src/core/runtime/types.ts @@ -51,6 +51,7 @@ export interface ChatTurnRequest { canvasSelection?: CanvasSelectionContext | null; externalContextPaths?: string[]; enabledMcpServers?: Set; + lineRangeMentions?: Map; } export interface PreparedChatTurn { diff --git a/src/features/chat/ClaudianView.ts b/src/features/chat/ClaudianView.ts index 8b138532e..c7732c8d2 100644 --- a/src/features/chat/ClaudianView.ts +++ b/src/features/chat/ClaudianView.ts @@ -549,6 +549,124 @@ export class ClaudianView extends ItemView { } }); + // Alt+K (Option+K on Mac): insert a line-range @mention from the current editor selection. + // Registered on document so it fires even when focus is in the editor pane. + this.registerDomEvent(document, 'keydown', (e: KeyboardEvent) => { + if (!e.altKey || e.code !== 'KeyK' || e.isComposing) return; + + const activeTab = this.tabManager?.getActiveTab(); + if (!activeTab) return; + + const selectionController = activeTab.controllers.selectionController; + const fileContextManager = activeTab.ui.fileContextManager; + const inputEl = activeTab.dom.inputEl; + if (!selectionController || !fileContextManager || !inputEl) return; + + const ctx = selectionController.getContext(); + if (!ctx || ctx.mode !== 'selection') return; + + // Reject the sentinel notePath set when view.file is null + if (!ctx.notePath || ctx.notePath === 'unknown') return; + + const filename = ctx.notePath.split('/').pop() ?? ctx.notePath; + const current = inputEl.value; + const needsSpace = current.length > 0 && !/\s$/.test(current); + + if (ctx.startLine !== undefined) { + // Source mode: line numbers are known — insert @mention token and register for send-time resolution + const start = ctx.startLine; + const end = start + (ctx.lineCount ?? 1) - 1; + const mentionText = start === end ? `@${filename}#${start}` : `@${filename}#${start}-${end}`; + inputEl.value = current + (needsSpace ? ' ' : '') + mentionText + ' '; + inputEl.selectionStart = inputEl.selectionEnd = inputEl.value.length; + fileContextManager.attachFile(ctx.notePath); + fileContextManager.attachLineRangeMention(ctx.notePath, start, end); + } else { + // Reading mode: no line numbers — inline the selected text directly as an editor_selection block + const block = `\n${ctx.selectedText}\n`; + inputEl.value = current + (needsSpace ? '\n\n' : '') + block + '\n\n'; + inputEl.selectionStart = inputEl.selectionEnd = inputEl.value.length; + fileContextManager.attachFile(ctx.notePath); + } + + inputEl.dispatchEvent(new Event('input', { bubbles: true })); + inputEl.focus(); + + e.preventDefault(); + }); + + // Shift+drop: capture phase on document so we intercept before Obsidian's own drop handler. + // Without Shift, the drop falls through to Obsidian's default handling. + const onDragOver = (e: DragEvent) => { + if (!e.shiftKey) return; + if (!this.containerEl.contains(e.target as Node)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'link'; + }; + const onDrop = (e: DragEvent) => { + if (!e.shiftKey) return; + if (!this.containerEl.contains(e.target as Node)) return; + e.preventDefault(); + e.stopPropagation(); + + const activeTab = this.tabManager?.getActiveTab(); + if (!activeTab) return; + const inputEl = activeTab.dom.inputEl; + if (!inputEl) return; + + const dt = e.dataTransfer; + if (!dt) return; + + const vault = this.app.vault; + const mentions: string[] = []; + + // Obsidian internal drag: text/plain = "obsidian://open?vault=...&file=" + const textData = dt.getData('text/plain'); + if (textData) { + for (const raw of textData.split('\n')) { + const line = raw.trim(); + try { + const url = new URL(line); + const filePath = url.searchParams.get('file'); + if (filePath) { + const decoded = decodeURIComponent(filePath); + const vaultFile = vault.getAbstractFileByPath(decoded) ?? vault.getAbstractFileByPath(decoded + '.md'); + const mentionPath = vaultFile ? vaultFile.path : decoded; + mentions.push(`@${mentionPath}`); + if (vaultFile) activeTab.ui.fileContextManager?.attachFile(vaultFile.path); + } + } catch { + // not a valid URL, skip + } + } + } + + // Native OS file drop fallback (files dragged from Finder, etc.) + if (mentions.length === 0 && dt.files.length > 0) { + for (let i = 0; i < dt.files.length; i++) { + const fileName = dt.files[i].name; + const vaultFile = vault.getFiles().find((f) => f.name === fileName); + const mentionText = vaultFile ? `@${vaultFile.path}` : `@${fileName}`; + mentions.push(mentionText); + if (vaultFile) activeTab.ui.fileContextManager?.attachFile(vaultFile.path); + } + } + + if (mentions.length === 0) return; + const current = inputEl.value; + const needsSpace = current.length > 0 && !/\s$/.test(current); + inputEl.value = current + (needsSpace ? ' ' : '') + mentions.join(' ') + ' '; + inputEl.selectionStart = inputEl.selectionEnd = inputEl.value.length; + inputEl.dispatchEvent(new Event('input', { bubbles: true })); + inputEl.focus(); + }; + document.addEventListener('dragover', onDragOver, true); + document.addEventListener('drop', onDrop, true); + this.register(() => { + document.removeEventListener('dragover', onDragOver, true); + document.removeEventListener('drop', onDrop, true); + }); + // Register Escape on the view's Obsidian Scope to prevent Obsidian from // navigating away when Claudian is open as a main-area tab. // Returning false consumes the event (preventDefault + stops scope propagation). diff --git a/src/features/chat/controllers/InputController.ts b/src/features/chat/controllers/InputController.ts index 0434f2fdf..935ff4706 100644 --- a/src/features/chat/controllers/InputController.ts +++ b/src/features/chat/controllers/InputController.ts @@ -28,6 +28,7 @@ import type { BrowserSelectionContext } from '../../../utils/browser'; import type { CanvasSelectionContext } from '../../../utils/canvas'; import { formatDurationMmSs } from '../../../utils/date'; import type { EditorSelectionContext } from '../../../utils/editor'; +import { resolveLineRangeMentions } from '../../../utils/lineRangeMention'; import { appendMarkdownSnippet } from '../../../utils/markdown'; import { COMPLETION_FLAVOR_WORDS } from '../constants'; import { type InlineAskQuestionConfig, InlineAskUserQuestion } from '../rendering/InlineAskUserQuestion'; @@ -260,13 +261,25 @@ export class InputController { imageContextManager?.clearImages(); } - const { displayContent, turnRequest } = this.buildTurnSubmission({ + const submission = this.buildTurnSubmission({ content, images: imagesForMessage, editorContextOverride: options?.editorContextOverride, browserContextOverride: options?.browserContextOverride, canvasContextOverride: options?.canvasContextOverride, }); + const { displayContent } = submission; + let { turnRequest } = submission; + + if (turnRequest.lineRangeMentions && turnRequest.lineRangeMentions.size > 0) { + const vault = this.deps.plugin.app.vault; + const resolvedText = await resolveLineRangeMentions( + turnRequest.text, + turnRequest.lineRangeMentions, + (filePath) => vault.adapter.read(filePath), + ); + turnRequest = { ...turnRequest, text: resolvedText }; + } fileContextManager?.markCurrentNoteSent(); @@ -652,6 +665,7 @@ export class InputController { ? fileContextManager.transformContextMentions(options.content) : options.content; const enabledMcpServers = mcpServerSelector?.getEnabledServers(); + const lineRangeMentions = fileContextManager?.getLineRangeMentions(); return { displayContent: options.content, @@ -668,6 +682,9 @@ export class InputController { enabledMcpServers: enabledMcpServers && enabledMcpServers.size > 0 ? enabledMcpServers : undefined, + lineRangeMentions: lineRangeMentions && lineRangeMentions.size > 0 + ? lineRangeMentions + : undefined, }, }; } diff --git a/src/features/chat/ui/FileContext.ts b/src/features/chat/ui/FileContext.ts index 308d3437c..d42e8bf49 100644 --- a/src/features/chat/ui/FileContext.ts +++ b/src/features/chat/ui/FileContext.ts @@ -348,6 +348,20 @@ export class FileContextManager { return this.state.getMentionedMcpServers(); } + getLineRangeMentions(): Map { + return this.state.getLineRangeMentions(); + } + + attachFile(filePath: string): void { + this.state.attachFile(filePath); + this.callbacks.onChipsChanged?.(); + } + + attachLineRangeMention(filePath: string, startLine: number, endLine: number): void { + this.state.attachLineRangeMention(filePath, startLine, endLine); + this.callbacks.onChipsChanged?.(); + } + clearMcpMentions(): void { this.state.clearMcpMentions(); } diff --git a/src/features/chat/ui/ImageContext.ts b/src/features/chat/ui/ImageContext.ts index 401685bfc..02c75d8ed 100644 --- a/src/features/chat/ui/ImageContext.ts +++ b/src/features/chat/ui/ImageContext.ts @@ -117,6 +117,7 @@ export class ImageContextManager { } private handleDragEnter(e: DragEvent) { + if (e.shiftKey) return; e.preventDefault(); e.stopPropagation(); @@ -126,6 +127,7 @@ export class ImageContextManager { } private handleDragOver(e: DragEvent) { + if (e.shiftKey) return; e.preventDefault(); e.stopPropagation(); } @@ -152,6 +154,8 @@ export class ImageContextManager { } private async handleDrop(e: DragEvent) { + // Let Shift+drop pass through to the view-level handler (file @mention insertion) + if (e.shiftKey) return; e.preventDefault(); e.stopPropagation(); this.dropOverlay?.removeClass('visible'); diff --git a/src/features/chat/ui/file-context/state/FileContextState.ts b/src/features/chat/ui/file-context/state/FileContextState.ts index 1936ed00f..4b01272ab 100644 --- a/src/features/chat/ui/file-context/state/FileContextState.ts +++ b/src/features/chat/ui/file-context/state/FileContextState.ts @@ -3,6 +3,8 @@ export class FileContextState { private sessionStarted = false; private mentionedMcpServers: Set = new Set(); private currentNoteSent = false; + private lineRangeMentions: Map = new Map(); + getAttachedFiles(): Set { return new Set(this.attachedFiles); @@ -29,6 +31,7 @@ export class FileContextState { this.currentNoteSent = false; this.attachedFiles.clear(); this.clearMcpMentions(); + this.lineRangeMentions.clear(); } resetForLoadedConversation(hasMessages: boolean): void { @@ -36,6 +39,7 @@ export class FileContextState { this.attachedFiles.clear(); this.sessionStarted = hasMessages; this.clearMcpMentions(); + this.lineRangeMentions.clear(); } setAttachedFiles(files: string[]): void { @@ -80,4 +84,17 @@ export class FileContextState { addMentionedMcpServer(name: string): void { this.mentionedMcpServers.add(name); } + + getLineRangeMentions(): Map { + return new Map(this.lineRangeMentions); + } + + attachLineRangeMention(filePath: string, startLine: number, endLine: number): void { + this.lineRangeMentions.set(filePath, { startLine, endLine }); + } + + removeLineRangeMention(filePath: string): void { + this.lineRangeMentions.delete(filePath); + } } + diff --git a/src/utils/lineRangeMention.ts b/src/utils/lineRangeMention.ts new file mode 100644 index 000000000..fda4642a7 --- /dev/null +++ b/src/utils/lineRangeMention.ts @@ -0,0 +1,35 @@ +export interface LineRangeMention { + startLine: number; + endLine: number; +} + +export async function resolveLineRangeMentions( + prompt: string, + lineRangeMentions: Map, + readFile: (filePath: string) => Promise, +): Promise { + if (lineRangeMentions.size === 0) return prompt; + + const blocks: string[] = []; + + for (const [filePath, { startLine, endLine }] of lineRangeMentions) { + let content: string; + try { + content = await readFile(filePath); + } catch { + continue; + } + + const lines = content.split(/\r?\n/); + const clampedEnd = Math.min(endLine, lines.length); + const selectedLines = lines.slice(startLine - 1, clampedEnd); + const selectedText = selectedLines.join('\n'); + + blocks.push( + `\n${selectedText}\n` + ); + } + + if (blocks.length === 0) return prompt; + return `${prompt}\n\n${blocks.join('\n\n')}`; +} diff --git a/tests/unit/features/chat/controllers/InputController.test.ts b/tests/unit/features/chat/controllers/InputController.test.ts index 2dc3df488..77b56e277 100644 --- a/tests/unit/features/chat/controllers/InputController.test.ts +++ b/tests/unit/features/chat/controllers/InputController.test.ts @@ -37,6 +37,7 @@ function createMockFileContextManager() { shouldSendCurrentNote: jest.fn().mockReturnValue(false), markCurrentNoteSent: jest.fn(), transformContextMentions: jest.fn().mockImplementation((text: string) => text), + getLineRangeMentions: jest.fn().mockReturnValue(new Map()), }; } @@ -173,6 +174,7 @@ function createMockDeps(overrides: Partial = {}): InputCont shouldSendCurrentNote: jest.fn().mockReturnValue(false), markCurrentNoteSent: jest.fn(), transformContextMentions: jest.fn().mockImplementation((text: string) => text), + getLineRangeMentions: jest.fn().mockReturnValue(new Map()), }) as any, getImageContextManager: () => imageContextManager as any, getMcpServerSelector: () => null, @@ -916,6 +918,7 @@ describe('InputController - Message Queue', () => { shouldSendCurrentNote: jest.fn().mockImplementation(() => !currentNoteSent), markCurrentNoteSent: jest.fn().mockImplementation(() => { currentNoteSent = true; }), transformContextMentions: jest.fn().mockImplementation((text: string) => text), + getLineRangeMentions: jest.fn().mockReturnValue(new Map()), }; deps.getFileContextManager = () => fileContextManager as any; @@ -941,6 +944,7 @@ describe('InputController - Message Queue', () => { shouldSendCurrentNote: jest.fn().mockReturnValue(true), markCurrentNoteSent: jest.fn(), transformContextMentions: jest.fn().mockImplementation((text: string) => text), + getLineRangeMentions: jest.fn().mockReturnValue(new Map()), }; deps = createSendableDeps({ diff --git a/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts b/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts index c58a9df9e..000bb42d9 100644 --- a/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts +++ b/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts @@ -152,4 +152,47 @@ describe('FileContextState', () => { expect(changed).toBe(true); }); }); + + describe('line-range mentions', () => { + it('starts with empty line range map', () => { + expect(state.getLineRangeMentions().size).toBe(0); + }); + + it('stores a line range mention', () => { + state.attachLineRangeMention('CLAUDE.md', 9, 15); + const map = state.getLineRangeMentions(); + expect(map.get('CLAUDE.md')).toEqual({ startLine: 9, endLine: 15 }); + }); + + it('overwrites an existing entry for the same file', () => { + state.attachLineRangeMention('CLAUDE.md', 1, 5); + state.attachLineRangeMention('CLAUDE.md', 9, 15); + expect(state.getLineRangeMentions().get('CLAUDE.md')).toEqual({ startLine: 9, endLine: 15 }); + }); + + it('removes a line range mention', () => { + state.attachLineRangeMention('CLAUDE.md', 9, 15); + state.removeLineRangeMention('CLAUDE.md'); + expect(state.getLineRangeMentions().size).toBe(0); + }); + + it('clears line range mentions on resetForNewConversation', () => { + state.attachLineRangeMention('CLAUDE.md', 9, 15); + state.resetForNewConversation(); + expect(state.getLineRangeMentions().size).toBe(0); + }); + + it('clears line range mentions on resetForLoadedConversation', () => { + state.attachLineRangeMention('CLAUDE.md', 9, 15); + state.resetForLoadedConversation(true); + expect(state.getLineRangeMentions().size).toBe(0); + }); + + it('returns a copy so external mutations do not affect internal state', () => { + state.attachLineRangeMention('CLAUDE.md', 9, 15); + const map = state.getLineRangeMentions(); + map.clear(); + expect(state.getLineRangeMentions().size).toBe(1); + }); + }); }); diff --git a/tests/unit/utils/lineRangeMention.test.ts b/tests/unit/utils/lineRangeMention.test.ts new file mode 100644 index 000000000..388d3ce13 --- /dev/null +++ b/tests/unit/utils/lineRangeMention.test.ts @@ -0,0 +1,76 @@ +import { resolveLineRangeMentions } from '@/utils/lineRangeMention'; + +describe('resolveLineRangeMentions', () => { + const makeReadFile = (content: string) => + (_path: string) => Promise.resolve(content); + + it('returns prompt unchanged when map is empty', async () => { + const result = await resolveLineRangeMentions( + 'hello world', + new Map(), + makeReadFile('line1\nline2\nline3') + ); + expect(result).toBe('hello world'); + }); + + it('appends editor_selection XML for a single mention', async () => { + const fileContent = 'line1\nline2\nline3\nline4\nline5'; + const map = new Map([['notes/foo.md', { startLine: 2, endLine: 4 }]]); + const result = await resolveLineRangeMentions( + 'check this @foo.md#2-4', + map, + makeReadFile(fileContent) + ); + expect(result).toBe( + 'check this @foo.md#2-4\n\n' + + '\n' + + 'line2\nline3\nline4\n' + + '' + ); + }); + + it('appends multiple XML blocks for multiple mentions', async () => { + const map = new Map([ + ['a.md', { startLine: 1, endLine: 2 }], + ['b.md', { startLine: 3, endLine: 3 }], + ]); + const readFile = (path: string) => + path === 'a.md' + ? Promise.resolve('a1\na2\na3') + : Promise.resolve('b1\nb2\nb3'); + + const result = await resolveLineRangeMentions('prompt', map, readFile); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('clamps endLine when it exceeds file length', async () => { + const map = new Map([['x.md', { startLine: 3, endLine: 99 }]]); + const result = await resolveLineRangeMentions( + 'prompt', + map, + makeReadFile('l1\nl2\nl3') + ); + expect(result).toContain('lines="3-3"'); + expect(result).toContain('l3'); + expect(result).not.toContain('undefined'); + }); + + it('returns prompt unchanged when readFile throws', async () => { + const map = new Map([['missing.md', { startLine: 1, endLine: 2 }]]); + const readFile = () => Promise.reject(new Error('file not found')); + const result = await resolveLineRangeMentions('prompt', map, readFile); + expect(result).toBe('prompt'); + }); + + it('handles CRLF line endings correctly', async () => { + const map = new Map([['win.md', { startLine: 2, endLine: 3 }]]); + const result = await resolveLineRangeMentions( + 'prompt', + map, + makeReadFile('line1\r\nline2\r\nline3\r\nline4') + ); + expect(result).toContain('line2\nline3'); + expect(result).not.toContain('\r'); + }); +});