From 774393ccb7cf254a984184308c0f7960c6fc1168 Mon Sep 17 00:00:00 2001 From: tim-inkeep <132074086+tim-inkeep@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:58:26 -0400 Subject: [PATCH] fix(open-knowledge): ground inline Ask AI selection before terminal paste (#2341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(open-knowledge): ground inline Ask AI selection before terminal paste The WYSIWYG bubble-menu Ask AI button pasted the raw selected text into the running CLI. It now sends the same grounded selection prompt the bottom Ask AI composer builds (composeSelectionPrompt): the doc named as an @-mention plus the passage inline, or a locus read-via-OK-MCP pointer when the selection is large. Terminal reuse is unchanged; only the payload is grounded. Adds specs/2026-07-01-terminal-ask-ai-selection-grounding and a changeset. * test(open-knowledge): cover docName-null Ask AI fallback; use NOTE marker Addresses PR review: adds a DOM test for the docName === null composer fallback branch, and swaps the novel ponytail: comment prefix for the repo-standard NOTE: marker. * docs(open-knowledge): fix stale ponytail ref + non-Cursor threshold wording Addresses PR review: the spec still said ponytail: (code uses NOTE:), and both the call-site comment and spec called claude-code the 'most generous' target when only Cursor double-encodes — every non-Cursor target shares the same widest inline-vs-locus threshold. * test(open-knowledge): reuse makeEditor for docName-null case; fix stale comment Addresses PR pending recs: the docName-null test now reuses makeEditor (widened to accept null) instead of hand-building the editor, and the grounded-prompt test comment no longer says the host is 'mocked away' — the real pub/sub runs; only TerminalSessionsHost is unmounted. GitOrigin-RevId: 693c0ea134a405eb970cd3f692b35decd654fa76 --- .../terminal-ask-ai-selection-grounding.md | 5 ++++ .../EditWithAiBubbleButton.dom.test.tsx | 30 +++++++++++++++---- .../bubble-menu/EditWithAiBubbleButton.tsx | 22 +++++++++++--- 3 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 .changeset/terminal-ask-ai-selection-grounding.md 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', + }), + ); }); }} >