Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,4 @@ dev

.claude/
.codex/
docs/superpowers/
1 change: 1 addition & 0 deletions src/core/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface ChatTurnRequest {
canvasSelection?: CanvasSelectionContext | null;
externalContextPaths?: string[];
enabledMcpServers?: Set<string>;
lineRangeMentions?: Map<string, { startLine: number; endLine: number }>;
}

export interface PreparedChatTurn {
Expand Down
118 changes: 118 additions & 0 deletions src/features/chat/ClaudianView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<editor_selection path="${ctx.notePath}">\n${ctx.selectedText}\n</editor_selection>`;
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=<encoded-path>"
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).
Expand Down
19 changes: 18 additions & 1 deletion src/features/chat/controllers/InputController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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,
Expand All @@ -668,6 +682,9 @@ export class InputController {
enabledMcpServers: enabledMcpServers && enabledMcpServers.size > 0
? enabledMcpServers
: undefined,
lineRangeMentions: lineRangeMentions && lineRangeMentions.size > 0
? lineRangeMentions
: undefined,
},
};
}
Expand Down
14 changes: 14 additions & 0 deletions src/features/chat/ui/FileContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,20 @@ export class FileContextManager {
return this.state.getMentionedMcpServers();
}

getLineRangeMentions(): Map<string, { startLine: number; endLine: number }> {
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();
}
Expand Down
4 changes: 4 additions & 0 deletions src/features/chat/ui/ImageContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class ImageContextManager {
}

private handleDragEnter(e: DragEvent) {
if (e.shiftKey) return;
e.preventDefault();
e.stopPropagation();

Expand All @@ -126,6 +127,7 @@ export class ImageContextManager {
}

private handleDragOver(e: DragEvent) {
if (e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
}
Expand All @@ -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');
Expand Down
17 changes: 17 additions & 0 deletions src/features/chat/ui/file-context/state/FileContextState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export class FileContextState {
private sessionStarted = false;
private mentionedMcpServers: Set<string> = new Set();
private currentNoteSent = false;
private lineRangeMentions: Map<string, { startLine: number; endLine: number }> = new Map();


getAttachedFiles(): Set<string> {
return new Set(this.attachedFiles);
Expand All @@ -29,13 +31,15 @@ export class FileContextState {
this.currentNoteSent = false;
this.attachedFiles.clear();
this.clearMcpMentions();
this.lineRangeMentions.clear();
}

resetForLoadedConversation(hasMessages: boolean): void {
this.currentNoteSent = hasMessages;
this.attachedFiles.clear();
this.sessionStarted = hasMessages;
this.clearMcpMentions();
this.lineRangeMentions.clear();
}

setAttachedFiles(files: string[]): void {
Expand Down Expand Up @@ -80,4 +84,17 @@ export class FileContextState {
addMentionedMcpServer(name: string): void {
this.mentionedMcpServers.add(name);
}

getLineRangeMentions(): Map<string, { startLine: number; endLine: number }> {
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);
}
}

35 changes: 35 additions & 0 deletions src/utils/lineRangeMention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export interface LineRangeMention {
startLine: number;
endLine: number;
}

export async function resolveLineRangeMentions(
prompt: string,
lineRangeMentions: Map<string, LineRangeMention>,
readFile: (filePath: string) => Promise<string>,
): Promise<string> {
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(
`<editor_selection path="${filePath}" lines="${startLine}-${clampedEnd}">\n${selectedText}\n</editor_selection>`
);
}

if (blocks.length === 0) return prompt;
return `${prompt}\n\n${blocks.join('\n\n')}`;
}
4 changes: 4 additions & 0 deletions tests/unit/features/chat/controllers/InputController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
};
}

Expand Down Expand Up @@ -173,6 +174,7 @@ function createMockDeps(overrides: Partial<InputControllerDeps> = {}): 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,
Expand Down Expand Up @@ -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;
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading
Loading