Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/terminal-ask-ai-selection-grounding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

The editor's inline **Ask AI** button now sends a grounded prompt to the docked terminal instead of the raw selected text. When you highlight a passage and click Ask AI with a terminal open, the running CLI receives the passage together with a reference to the document it came from (or a pointer to read the full passage via the OpenKnowledge MCP server when the selection is large), so the agent can place it in your knowledge base instead of getting an unattributed blob.
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ const schema = new Schema({
},
});

/** A minimal fake TipTap editor. The click handler reads the selection text via
* `state.doc.textBetween(from, to)`; `collapsed` yields an empty selection so
* the caret-only → composer fallback can be exercised. */
function makeEditor(docName: string, text: string, collapsed = false): Editor {
/** A minimal fake TipTap editor. The click handler serializes the selection to
* markdown via `serializeWysiwygSelection` (which reads `selection.content()`);
* `collapsed` yields an empty selection so the caret-only → composer fallback
* can be exercised. A `null` docName leaves the editor with no registered doc
* name (`setEditorDocName` deletes the entry), exercising the ungrounded path. */
function makeEditor(docName: string | null, text: string, collapsed = false): Editor {
const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text(text)])]);
const from = 1;
const to = collapsed ? 1 : 1 + text.length;
Expand Down Expand Up @@ -147,15 +149,19 @@ describe('EditWithAiBubbleButton', () => {
expect(container.firstChild).toBeNull();
});

test('clicking the trigger sends the selected passage to the active terminal', async () => {
test('clicking the trigger sends a GROUNDED selection prompt to the active terminal', async () => {
setPlatform('MacIntel');
const user = userEvent.setup();
const editor = makeEditor('specs/foo/SPEC', 'A passage.');
renderButton({ editor });

await user.click(screen.getByTestId('edit-with-ai-bubble-button'));

await waitFor(() => expect(terminalInputs).toEqual(['A passage.']));
await waitFor(() => expect(terminalInputs).toHaveLength(1));
const [prompt] = terminalInputs;
expect(prompt).toContain('@specs/foo/SPEC.md');
expect(prompt).toContain('A passage.');
expect(prompt).not.toBe('A passage.');
expect(openRequests).toBe(0);
});

Expand All @@ -171,6 +177,18 @@ describe('EditWithAiBubbleButton', () => {
expect(terminalInputs).toEqual([]);
});

test('clicking with no registered doc name opens the composer instead', async () => {
setPlatform('MacIntel');
const user = userEvent.setup();
const editor = makeEditor(null, 'A passage.');
renderButton({ editor });

await user.click(screen.getByTestId('edit-with-ai-bubble-button'));

await waitFor(() => expect(openRequests).toBe(1));
expect(terminalInputs).toEqual([]);
});

test('Cmd+Shift+I requests the Ask AI composer open+focus', async () => {
setPlatform('MacIntel');
const editor = makeEditor('specs/foo/SPEC', 'A passage.');
Expand Down
22 changes: 18 additions & 4 deletions packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { composeSelectionPrompt } from '@inkeep/open-knowledge-core';
import { Trans } from '@lingui/react/macro';
import { isMacOS } from '@tiptap/core';
import type { Editor } from '@tiptap/react';
Expand All @@ -9,6 +10,9 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts';
import { docNameToRelativePath } from '@/lib/workspace-paths';
import { serializeWysiwygSelection } from '../edit-with-ai-selection';
import { getEditorDocName } from '../extensions/doc-context';

function isNativeTextControl(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
Expand Down Expand Up @@ -63,11 +67,21 @@ function EditWithAiBubbleMenu({
data-testid="edit-with-ai-bubble-button"
className="gap-1 px-2 text-sm font-medium text-accent-foreground/80"
onClick={() => {
const { from, to } = editor.state.selection;
const text = editor.state.doc.textBetween(from, to, '\n');
const docName = getEditorDocName(editor);
const selectionMarkdown = serializeWysiwygSelection(editor);
requestAnimationFrame(() => {
if (text.trim() === '') emitOpenAskAiComposer();
else requestActiveTerminalInput(text);
if (docName === null || selectionMarkdown.trim() === '') {
emitOpenAskAiComposer();
return;
}
requestActiveTerminalInput(
composeSelectionPrompt({
relativePath: docNameToRelativePath(docName),
instruction: '',
selectionMarkdown,
target: 'claude-code',
}),
);
});
}}
>
Expand Down