diff --git a/.changeset/terminal-ask-ai-selection-grounding.md b/.changeset/terminal-ask-ai-selection-grounding.md new file mode 100644 index 000000000..69a987de2 --- /dev/null +++ b/.changeset/terminal-ask-ai-selection-grounding.md @@ -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. diff --git a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx index d33fcf672..6949dcf2d 100644 --- a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx +++ b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx @@ -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; @@ -147,7 +149,7 @@ 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.'); @@ -155,7 +157,11 @@ describe('EditWithAiBubbleButton', () => { 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); }); @@ -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.'); diff --git a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx index 8d66872d3..3e8d5d9fc 100644 --- a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx +++ b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx @@ -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'; @@ -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; @@ -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', + }), + ); }); }} >