Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/shared/src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -155,6 +157,8 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
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,
Expand Down Expand Up @@ -189,6 +193,7 @@ export const PromptInput = forwardRef<EditorHandle, PromptInputProps>(
bashMode: enableBashMode,
commands: enableCommands,
},
codeBlocks: codeBlocksEnabled,
getPromptHistory,
onPromptRecall,
onBeforeSubmit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
});
},
};
},
});
97 changes: 97 additions & 0 deletions packages/ui/src/features/message-editor/tiptap/codeFence.test.ts
Original file line number Diff line number Diff line change
@@ -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("<p>```js</p>");
const fence = findCodeFenceLine(editor.view);
expect(fence).toMatchObject({ language: "js", afterHardBreak: false });
});

it("matches a fence on the line after a hard break", () => {
buildEditor("<p>some text<br>```</p>");
const fence = findCodeFenceLine(editor.view);
expect(fence).toMatchObject({ language: "", afterHardBreak: true });
});

it.each([
{ name: "text before the fence on the same line", html: "<p>text ```</p>" },
{ name: "no fence at all", html: "<p>hello</p>" },
{ name: "a quadruple backtick run", html: "<p>````</p>" },
])("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("<p>some text<br>```py</p>");

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("<p>```js</p>");

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("<p>text ```</p>");
expect(convertFenceLine(editor.view)).toBe(false);
});
});

describe("space after ```", () => {
it("does not create a code block (Enter is the only trigger)", () => {
buildEditor("<p>```</p>");
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");
});
});
80 changes: 80 additions & 0 deletions packages/ui/src/features/message-editor/tiptap/codeFence.ts
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 31 additions & 0 deletions packages/ui/src/features/message-editor/tiptap/extensions.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
21 changes: 20 additions & 1 deletion packages/ui/src/features/message-editor/tiptap/extensions.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -20,6 +32,7 @@ export function getEditorExtensions(options: EditorExtensionsOptions) {
fileMentions = true,
issueMentions = true,
commands = true,
codeBlocks = false,
} = options;

const extensions = [
Expand All @@ -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));
}
Expand Down
Loading
Loading