From 3c103cb6d0bf0930afd6c45fffcd2fb859011bd7 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Mon, 25 May 2026 08:36:02 +0300 Subject: [PATCH 1/4] fix(code): show user skills in running tasks and trigger / after spaces Two bugs in the slash-command popup, fixed together: 1. Skills popup only showed `/good /bad /feedback` in running tasks. `getCommandSuggestions` ignored the trpc-fetched skills list whenever the session had a `taskId`, so the popup waited on the agent's async `available_commands_update` and only saw the hard-coded session commands until it arrived. Now we fall back to the draft-store skills list whenever the session-command list is empty; agent commands still take over once they arrive. Also moves the trpc skills loader into `PromptInput` so every prompt with commands enabled populates the fallback (previously only `TaskInput` did). 2. Popup only appeared when `/` was at the start of the line. `CommandMention` set `startOfLine: true` (the only mention type to do so), so `please /foo` did not trigger. Dropping the flag restores parity with `@` and `#`, while Tiptap's default `allowedPrefixes: [" "]` still prevents file paths like `abc/foo` from triggering. Tests in `getSuggestions.test.ts` cover the new fallback paths. Generated-By: PostHog Code Task-Id: 300b3a9a-c74e-42a2-9099-5ede198c7570 --- .../message-editor/components/PromptInput.tsx | 26 +++ .../suggestions/getSuggestions.test.ts | 160 ++++++++++++++++++ .../suggestions/getSuggestions.ts | 11 +- .../message-editor/tiptap/CommandMention.ts | 1 - .../task-detail/components/TaskInput.tsx | 18 +- 5 files changed, 195 insertions(+), 21 deletions(-) create mode 100644 apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index 4e5482872..0c1bc1730 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -4,6 +4,7 @@ import { ArrowUp, Stop } from "@phosphor-icons/react"; import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { cycleModeOption } from "@renderer/features/sessions/stores/sessionStore"; +import { trpcClient } from "@renderer/trpc/client"; import { EditorContent } from "@tiptap/react"; import { hasOpenOverlay } from "@utils/overlay"; import clsx from "clsx"; @@ -182,6 +183,31 @@ export const PromptInput = forwardRef( clearFocusRequest(sessionId); }, [focusRequested, focus, clearFocusRequest, sessionId, isReady]); + // Populate the draft-store skills list as a fallback for the / command + // popup. The agent emits an `available_commands_update` shortly after a + // session starts, but typing `/` before that arrives would otherwise show + // only the built-in /good /bad /feedback commands. + useEffect(() => { + if (!enableCommands) return; + let cancelled = false; + trpcClient.skills.list + .query() + .then((skills) => { + if (cancelled) return; + useDraftStore.getState().actions.setCommands( + sessionId, + skills.map((s) => ({ name: s.name, description: s.description })), + ); + }) + .catch(() => { + // Best-effort fallback — agent-supplied commands remain authoritative. + }); + return () => { + cancelled = true; + useDraftStore.getState().actions.clearCommands(sessionId); + }; + }, [sessionId, enableCommands]); + useHotkeys( "escape", (e) => { diff --git a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts new file mode 100644 index 000000000..e1cdc2710 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts @@ -0,0 +1,160 @@ +import type { AcpMessage } from "@shared/types/session-events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@renderer/trpc/client", () => ({ + trpc: { + git: { + searchGithubRefs: { + queryOptions: () => ({ queryKey: [], queryFn: () => [] }), + }, + }, + }, +})); + +vi.mock("@renderer/trpc", () => ({ + trpcClient: { + secureStore: { + getItem: { query: vi.fn().mockResolvedValue(null) }, + setItem: { query: vi.fn().mockResolvedValue(undefined) }, + removeItem: { query: vi.fn().mockResolvedValue(undefined) }, + }, + }, +})); + +import { useSessionStore } from "../../sessions/stores/sessionStore"; +import { useDraftStore } from "../stores/draftStore"; +import { getCommandSuggestions } from "./getSuggestions"; + +const SESSION_ID = "task-123"; +const TASK_ID = "task-123"; +const TASK_RUN_ID = "run-1"; + +function seedDraftCommands(commands: { name: string; description: string }[]) { + useDraftStore.getState().actions.setCommands(SESSION_ID, commands); +} + +function seedSessionContext(taskId: string | undefined) { + useDraftStore.getState().actions.setContext(SESSION_ID, { taskId }); +} + +function seedSessionAvailableCommands( + commands: { name: string; description: string }[], +) { + const events: AcpMessage[] = [ + { + direction: "agent_to_client", + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: TASK_RUN_ID, + update: { + sessionUpdate: "available_commands_update", + availableCommands: commands, + }, + }, + }, + } as unknown as AcpMessage, + ]; + + useSessionStore.setState((state) => { + state.sessions[TASK_RUN_ID] = { + taskId: TASK_ID, + taskRunId: TASK_RUN_ID, + events, + processedLineCount: 0, + configOptions: [], + pendingPermissions: new Map(), + messageQueue: [], + optimisticItems: [], + } as unknown as (typeof state.sessions)[string]; + state.taskIdIndex[TASK_ID] = TASK_RUN_ID; + }); +} + +function resetStores() { + useDraftStore.setState((state) => { + state.commands = {}; + state.contexts = {}; + }); + useSessionStore.setState((state) => { + state.sessions = {}; + state.taskIdIndex = {}; + }); +} + +interface Scenario { + name: string; + contextTaskId?: string; + sessionCommands?: { name: string; description: string }[]; + draftCommands?: { name: string; description: string }[]; + expectContains: string[]; + expectNotContains?: string[]; +} + +const SCENARIOS: Scenario[] = [ + { + name: "built-ins are always present", + expectContains: ["good", "bad", "feedback"], + }, + { + name: "agent-supplied skills surface from session events", + contextTaskId: TASK_ID, + sessionCommands: [ + { name: "review", description: "Review code" }, + { name: "ship-it", description: "Ship the change" }, + ], + expectContains: ["review", "ship-it"], + }, + { + name: "falls back to draft-store skills when session has no commands_update yet", + contextTaskId: TASK_ID, + draftCommands: [{ name: "review", description: "Review code" }], + expectContains: ["review"], + }, + { + name: "agent-supplied commands win over draft-store fallback once reported", + contextTaskId: TASK_ID, + draftCommands: [ + { name: "fallback-only", description: "Should not appear" }, + ], + sessionCommands: [{ name: "agent-cmd", description: "From agent" }], + expectContains: ["agent-cmd"], + expectNotContains: ["fallback-only"], + }, + { + name: "uses draft-store skills when there is no running task", + draftCommands: [{ name: "my-skill", description: "User skill" }], + expectContains: ["my-skill"], + }, +]; + +describe("getCommandSuggestions", () => { + beforeEach(resetStores); + + it.each(SCENARIOS)( + "$name", + ({ + contextTaskId, + sessionCommands, + draftCommands, + expectContains, + expectNotContains, + }) => { + if (contextTaskId) seedSessionContext(contextTaskId); + if (draftCommands) seedDraftCommands(draftCommands); + if (sessionCommands) seedSessionAvailableCommands(sessionCommands); + + const names = getCommandSuggestions(SESSION_ID, "").map( + (s) => s.command.name, + ); + + for (const expected of expectContains) { + expect(names).toContain(expected); + } + for (const unexpected of expectNotContains ?? []) { + expect(names).not.toContain(unexpected); + } + }, + ); +}); diff --git a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts index 14aded371..f09eb2ac4 100644 --- a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts @@ -159,9 +159,14 @@ export function getCommandSuggestions( ): CommandSuggestionItem[] { const store = useDraftStore.getState(); const taskId = store.contexts[sessionId]?.taskId; - const agentCommands = taskId - ? getAvailableCommandsForTask(taskId) - : (store.commands[sessionId] ?? []); + // Agent commands (from `available_commands_update`) are the source of truth + // once a session has reported them, but they arrive async after session + // startup — fall back to the trpc-fetched skills list so users don't see only + // the built-in /good /bad /feedback commands during that window. + const sessionCommands = taskId ? getAvailableCommandsForTask(taskId) : []; + const draftCommands = store.commands[sessionId] ?? []; + const agentCommands = + sessionCommands.length > 0 ? sessionCommands : draftCommands; const merged = [...CODE_COMMANDS, ...agentCommands]; const commands = [...new Map(merged.map((cmd) => [cmd.name, cmd])).values()]; const filtered = searchCommands(commands, query); diff --git a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts b/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts index 399a300e5..815bcdaf7 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts @@ -13,7 +13,6 @@ export function createCommandMention(options: CommandMentionOptions) { name: "commandMention", char: "/", chipType: "command", - startOfLine: true, autoCommit: true, items: (query) => sessionId ? getCommandSuggestions(sessionId, query) : [], diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 85253604a..b4c2b3475 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -38,7 +38,7 @@ import { ButtonGroup } from "@posthog/quill"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { useAuthStore } from "@renderer/features/auth/stores/authStore"; import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import { type TaskInputReportAssociation, @@ -499,22 +499,6 @@ export function TaskInput({ const { isOnline } = useConnectivity(); const promptSessionId = sessionId; - // Populate command list for @ file mentions + / skills on mount - useEffect(() => { - let cancelled = false; - trpcClient.skills.list.query().then((skills) => { - if (cancelled) return; - useDraftStore.getState().actions.setCommands( - promptSessionId, - skills.map((s) => ({ name: s.name, description: s.description })), - ); - }); - return () => { - cancelled = true; - useDraftStore.getState().actions.clearCommands(promptSessionId); - }; - }, [promptSessionId]); - const hasHistory = useTaskInputHistoryStore((s) => s.entries.length > 0); const getPromptHistory = useCallback( () => useTaskInputHistoryStore.getState().entries.map((e) => e.text), From 4e58ccd8a430872a8bf8bfae19efb2d32c00c796 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Mon, 25 May 2026 08:57:27 +0300 Subject: [PATCH 2/4] fix(code): distinguish "no command_update yet" from "agent sent empty" `extractAvailableCommandsFromEvents` returned `[]` both when no `available_commands_update` had arrived and when the agent reported one with `availableCommands: []`. The `getCommandSuggestions` fallback then fired in both cases, which contradicted the invariant that agent-supplied commands are authoritative once they arrive. Return `null` from the extractor (and from `getAvailableCommandsForTask` / `useAvailableCommandsForTask`) to mean "not yet received"; `[]` now means "agent reported empty" and the draft-store fallback is skipped. Adds a parameterised test for the new case. Generated-By: PostHog Code Task-Id: 456f79c0-c95d-4e46-8888-0087ead45124 --- .../suggestions/getSuggestions.test.ts | 10 ++++++++ .../suggestions/getSuggestions.ts | 15 ++++++------ .../features/sessions/hooks/useSession.ts | 24 ++++++++++++------- apps/code/src/renderer/utils/session.ts | 6 +++-- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts index e1cdc2710..0b643b2df 100644 --- a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.test.ts @@ -127,6 +127,16 @@ const SCENARIOS: Scenario[] = [ draftCommands: [{ name: "my-skill", description: "User skill" }], expectContains: ["my-skill"], }, + { + name: "agent reporting an empty list suppresses the draft-store fallback", + contextTaskId: TASK_ID, + draftCommands: [ + { name: "fallback-only", description: "Should not appear" }, + ], + sessionCommands: [], + expectContains: ["good", "bad", "feedback"], + expectNotContains: ["fallback-only"], + }, ]; describe("getCommandSuggestions", () => { diff --git a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts index f09eb2ac4..1a05728f4 100644 --- a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts @@ -159,14 +159,15 @@ export function getCommandSuggestions( ): CommandSuggestionItem[] { const store = useDraftStore.getState(); const taskId = store.contexts[sessionId]?.taskId; - // Agent commands (from `available_commands_update`) are the source of truth - // once a session has reported them, but they arrive async after session - // startup — fall back to the trpc-fetched skills list so users don't see only - // the built-in /good /bad /feedback commands during that window. - const sessionCommands = taskId ? getAvailableCommandsForTask(taskId) : []; + // Agent commands (from `available_commands_update`) are authoritative once a + // session has reported them, but they arrive async after session startup — + // fall back to the trpc-fetched skills list so users don't see only the + // built-in /good /bad /feedback commands during that window. `null` means + // "agent hasn't reported yet"; an empty array means "agent reported empty" + // and we respect it. + const sessionCommands = taskId ? getAvailableCommandsForTask(taskId) : null; const draftCommands = store.commands[sessionId] ?? []; - const agentCommands = - sessionCommands.length > 0 ? sessionCommands : draftCommands; + const agentCommands = sessionCommands ?? draftCommands; const merged = [...CODE_COMMANDS, ...agentCommands]; const commands = [...new Map(merged.map((cmd) => [cmd.name, cmd])).values()]; const filtered = searchCommands(commands, query); diff --git a/apps/code/src/renderer/features/sessions/hooks/useSession.ts b/apps/code/src/renderer/features/sessions/hooks/useSession.ts index 12edb747c..31326083d 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSession.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSession.ts @@ -30,28 +30,36 @@ export const useSessionForTask = ( return s.sessions[taskRunId]; }); +/** + * Returns `null` when the agent hasn't sent an `available_commands_update` yet, + * so callers can distinguish that from an explicit empty list. + */ export const useAvailableCommandsForTask = ( taskId: string | undefined, -): AvailableCommand[] => { +): AvailableCommand[] | null => { return useSessionStore((s) => { - if (!taskId) return []; + if (!taskId) return null; const taskRunId = s.taskIdIndex[taskId]; - if (!taskRunId) return []; + if (!taskRunId) return null; const session = s.sessions[taskRunId]; - if (!session?.events) return []; + if (!session?.events) return null; return extractAvailableCommandsFromEvents(session.events); }, shallow); }; +/** + * Returns `null` when the agent hasn't sent an `available_commands_update` yet, + * so callers can distinguish that from an explicit empty list. + */ export function getAvailableCommandsForTask( taskId: string | undefined, -): AvailableCommand[] { - if (!taskId) return []; +): AvailableCommand[] | null { + if (!taskId) return null; const state = useSessionStore.getState(); const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return []; + if (!taskRunId) return null; const session = state.sessions[taskRunId]; - if (!session?.events) return []; + if (!session?.events) return null; return extractAvailableCommandsFromEvents(session.events); } diff --git a/apps/code/src/renderer/utils/session.ts b/apps/code/src/renderer/utils/session.ts index ec99a997d..721d6ca9d 100644 --- a/apps/code/src/renderer/utils/session.ts +++ b/apps/code/src/renderer/utils/session.ts @@ -155,10 +155,12 @@ export function convertStoredEntriesToEvents( /** * Extract available commands from session events. * Scans backwards to find the most recent available_commands_update. + * Returns `null` if the agent has not emitted one yet — callers can use this + * to distinguish "not yet received" from "received an empty list". */ export function extractAvailableCommandsFromEvents( events: AcpMessage[], -): AvailableCommand[] { +): AvailableCommand[] | null { for (let i = events.length - 1; i >= 0; i--) { const msg = events[i].message; if ( @@ -174,7 +176,7 @@ export function extractAvailableCommandsFromEvents( } } } - return []; + return null; } /** From c549d5676ec3a6de5e181806ff91eaafbbf4e8d8 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Mon, 25 May 2026 09:03:08 +0300 Subject: [PATCH 3/4] fix(code): drop slash-command autofill so the popover always shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mid-prompt `/` was matching the popover behavior advertised for `@` and `#`, but two separate autofill paths kept hijacking the experience: - `autoCommit: true` on `CommandMention` committed the chip as soon as the query exactly matched a label, closing the popover before the user could scan it. - `CommandGhostText` rendered inline ghost text (Tab to accept) whenever a `/query` appeared mid-prompt, instead of surfacing the same suggestion popover used at the start of the line. Remove both. `/` now opens the popover at the start of a line and after a space, with arrow-key/enter selection — matching `@` and `#`. The unused `autoCommit` plumbing in `createSuggestionMention` goes with it. Generated-By: PostHog Code Task-Id: 456f79c0-c95d-4e46-8888-0087ead45124 --- .../message-editor/tiptap/CommandGhostText.ts | 176 ------------------ .../message-editor/tiptap/CommandMention.ts | 1 - .../tiptap/createSuggestionMention.ts | 23 --- .../message-editor/tiptap/extensions.ts | 2 - 4 files changed, 202 deletions(-) delete mode 100644 apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts diff --git a/apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts b/apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts deleted file mode 100644 index 4bdc365d1..000000000 --- a/apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Extension } from "@tiptap/core"; -import type { EditorState, Transaction } from "@tiptap/pm/state"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { getCommandSuggestions } from "../suggestions/getSuggestions"; -import type { CommandSuggestionItem } from "../types"; - -interface GhostMatch { - slashPos: number; - cursorPos: number; - query: string; - item: CommandSuggestionItem; -} - -interface PluginState { - ghost: GhostMatch | null; - dismissedAt: number | null; -} - -type GhostMeta = { type: "dismiss" } | { type: "reset" }; - -const pluginKey = new PluginKey("commandGhostText"); -const SLASH_QUERY_REGEX = /(?:^|\s)\/([^\s/]+)$/; - -const getGhost = (state: EditorState): GhostMatch | null => - pluginKey.getState(state)?.ghost ?? null; - -function computeGhost( - sessionId: string, - state: EditorState, -): GhostMatch | null { - if (!sessionId) return null; - const { selection } = state; - if (!selection.empty) return null; - - const $from = selection.$from; - const textBeforeCursor = $from.parent.textBetween( - 0, - $from.parentOffset, - "\n", - "\uFFFC", - ); - - const match = SLASH_QUERY_REGEX.exec(textBeforeCursor); - if (!match) return null; - - const query = match[1]; - const slashPos = - $from.start() + match.index + (match[0].length - query.length - 1); - - if (state.doc.resolve(slashPos).parentOffset === 0) return null; - - const top = getCommandSuggestions(sessionId, query)[0]; - if (!top) return null; - - const lowerLabel = top.label.toLowerCase(); - const lowerQuery = query.toLowerCase(); - if (!lowerLabel.startsWith(lowerQuery) || lowerLabel === lowerQuery) { - return null; - } - - return { slashPos, cursorPos: selection.from, query, item: top }; -} - -function createGhostWidget(text: string): HTMLElement { - const span = document.createElement("span"); - span.textContent = text; - span.className = "cli-command-ghost pointer-events-none text-[var(--gray-9)]"; - return span; -} - -function acceptGhost( - state: EditorState, - dispatch: (tr: Transaction) => void, -): boolean { - const ghost = getGhost(state); - if (!ghost) return false; - - const chipType = state.schema.nodes.mentionChip; - if (!chipType) return false; - - const chip = chipType.create({ - type: "command", - id: ghost.item.id, - label: ghost.item.label, - pastedText: false, - }); - const space = state.schema.text(" "); - - dispatch( - state.tr - .replaceWith(ghost.slashPos, ghost.cursorPos, [chip, space]) - .setMeta(pluginKey, { type: "reset" } satisfies GhostMeta), - ); - return true; -} - -export function createCommandGhostText(sessionId: string) { - return Extension.create({ - name: "commandGhostText", - - addProseMirrorPlugins() { - return [ - new Plugin({ - key: pluginKey, - state: { - init: () => ({ ghost: null, dismissedAt: null }), - apply: (tr, prev, _old, next) => { - const meta = tr.getMeta(pluginKey) as GhostMeta | undefined; - - if (meta?.type === "reset") { - return { ghost: null, dismissedAt: null }; - } - - const ghost = computeGhost(sessionId, next); - - if (meta?.type === "dismiss") { - return { ghost: null, dismissedAt: ghost?.slashPos ?? null }; - } - - const suppressed = - prev.dismissedAt !== null && - ghost?.slashPos === prev.dismissedAt; - - if (suppressed) { - return { ghost: null, dismissedAt: prev.dismissedAt }; - } - - return { ghost, dismissedAt: null }; - }, - }, - props: { - decorations(state) { - const ghost = getGhost(state); - if (!ghost) return null; - - const remainder = ghost.item.label.slice(ghost.query.length); - if (!remainder) return null; - - return DecorationSet.create(state.doc, [ - Decoration.widget( - ghost.cursorPos, - createGhostWidget(remainder), - { - side: 1, - key: "command-ghost", - }, - ), - ]); - }, - handleKeyDown(view, event) { - if (!getGhost(view.state)) return false; - - if (event.key === "Tab") { - event.preventDefault(); - return acceptGhost(view.state, view.dispatch); - } - - if (event.key === "Escape") { - event.preventDefault(); - view.dispatch( - view.state.tr.setMeta(pluginKey, { - type: "dismiss", - } satisfies GhostMeta), - ); - return true; - } - - return false; - }, - }, - }), - ]; - }, - }); -} diff --git a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts b/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts index 815bcdaf7..fff6f47f3 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts @@ -13,7 +13,6 @@ export function createCommandMention(options: CommandMentionOptions) { name: "commandMention", char: "/", chipType: "command", - autoCommit: true, items: (query) => sessionId ? getCommandSuggestions(sessionId, query) : [], resolveChipAttrs: (item) => { diff --git a/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts b/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts index 0568e04e5..8d48b654a 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts @@ -19,11 +19,6 @@ export interface SuggestionMentionConfig { debounceMs?: number; items: (query: string) => T[] | Promise; renderItem?: (item: T) => ReactNode; - /** - * When true, commit the suggestion as soon as the typed query exactly matches - * an item's label and no other item label extends it. - */ - autoCommit?: boolean; /** Override the chip attrs inserted for a given item. */ resolveChipAttrs?: (item: T) => Partial; /** Fires after the chip is inserted into the document. */ @@ -42,7 +37,6 @@ export function createSuggestionMention( debounceMs = 0, items: loadItems, renderItem, - autoCommit = false, resolveChipAttrs, onAfterInsert, } = config; @@ -124,23 +118,6 @@ export function createSuggestionMention( getReferenceClientRect: props.clientRect as () => DOMRect, }); } - - if (autoCommit) { - // Caveat: if one item label is a strict prefix of another (e.g. - // "add" vs "add-dir"), the shorter name becomes uncommittable via - // auto-commit and the user has to pick from the list. Avoid - // shipping prefix-clashing command names, or rename to disambiguate. - const q = props.query.toLowerCase(); - const exact = props.items.find((i) => i.label.toLowerCase() === q); - const hasLongerExtension = props.items.some( - (i) => - i.label.toLowerCase().startsWith(q) && - i.label.length > q.length, - ); - if (exact && !hasLongerExtension) { - props.command(exact); - } - } }, onKeyDown: (props) => { diff --git a/apps/code/src/renderer/features/message-editor/tiptap/extensions.ts b/apps/code/src/renderer/features/message-editor/tiptap/extensions.ts index 6eb8deb02..64330e77b 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/extensions.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/extensions.ts @@ -1,6 +1,5 @@ import Placeholder from "@tiptap/extension-placeholder"; import StarterKit from "@tiptap/starter-kit"; -import { createCommandGhostText } from "./CommandGhostText"; import { createCommandMention } from "./CommandMention"; import { createFileMention } from "./FileMention"; import { createIssueMention } from "./IssueMention"; @@ -51,7 +50,6 @@ export function getEditorExtensions(options: EditorExtensionsOptions) { if (commands) { extensions.push(createCommandMention({ sessionId })); - extensions.push(createCommandGhostText(sessionId)); } return extensions; From 538db06e444fab4bada0a46ec1ab883278570e33 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Mon, 25 May 2026 09:07:08 +0300 Subject: [PATCH 4/4] refactor(code): keep ghost-text and autoCommit infra around MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the deletions of `CommandGhostText` and the `autoCommit` plumbing on `createSuggestionMention`. The autofill behavior is still disabled — we just don't register the extension or pass the option from `CommandMention` — but the code stays in tree so we can flip it back on later without re-writing it. Generated-By: PostHog Code Task-Id: 456f79c0-c95d-4e46-8888-0087ead45124 --- .../message-editor/tiptap/CommandGhostText.ts | 176 ++++++++++++++++++ .../tiptap/createSuggestionMention.ts | 23 +++ 2 files changed, 199 insertions(+) create mode 100644 apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts diff --git a/apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts b/apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts new file mode 100644 index 000000000..4bdc365d1 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts @@ -0,0 +1,176 @@ +import { Extension } from "@tiptap/core"; +import type { EditorState, Transaction } from "@tiptap/pm/state"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { getCommandSuggestions } from "../suggestions/getSuggestions"; +import type { CommandSuggestionItem } from "../types"; + +interface GhostMatch { + slashPos: number; + cursorPos: number; + query: string; + item: CommandSuggestionItem; +} + +interface PluginState { + ghost: GhostMatch | null; + dismissedAt: number | null; +} + +type GhostMeta = { type: "dismiss" } | { type: "reset" }; + +const pluginKey = new PluginKey("commandGhostText"); +const SLASH_QUERY_REGEX = /(?:^|\s)\/([^\s/]+)$/; + +const getGhost = (state: EditorState): GhostMatch | null => + pluginKey.getState(state)?.ghost ?? null; + +function computeGhost( + sessionId: string, + state: EditorState, +): GhostMatch | null { + if (!sessionId) return null; + const { selection } = state; + if (!selection.empty) return null; + + const $from = selection.$from; + const textBeforeCursor = $from.parent.textBetween( + 0, + $from.parentOffset, + "\n", + "\uFFFC", + ); + + const match = SLASH_QUERY_REGEX.exec(textBeforeCursor); + if (!match) return null; + + const query = match[1]; + const slashPos = + $from.start() + match.index + (match[0].length - query.length - 1); + + if (state.doc.resolve(slashPos).parentOffset === 0) return null; + + const top = getCommandSuggestions(sessionId, query)[0]; + if (!top) return null; + + const lowerLabel = top.label.toLowerCase(); + const lowerQuery = query.toLowerCase(); + if (!lowerLabel.startsWith(lowerQuery) || lowerLabel === lowerQuery) { + return null; + } + + return { slashPos, cursorPos: selection.from, query, item: top }; +} + +function createGhostWidget(text: string): HTMLElement { + const span = document.createElement("span"); + span.textContent = text; + span.className = "cli-command-ghost pointer-events-none text-[var(--gray-9)]"; + return span; +} + +function acceptGhost( + state: EditorState, + dispatch: (tr: Transaction) => void, +): boolean { + const ghost = getGhost(state); + if (!ghost) return false; + + const chipType = state.schema.nodes.mentionChip; + if (!chipType) return false; + + const chip = chipType.create({ + type: "command", + id: ghost.item.id, + label: ghost.item.label, + pastedText: false, + }); + const space = state.schema.text(" "); + + dispatch( + state.tr + .replaceWith(ghost.slashPos, ghost.cursorPos, [chip, space]) + .setMeta(pluginKey, { type: "reset" } satisfies GhostMeta), + ); + return true; +} + +export function createCommandGhostText(sessionId: string) { + return Extension.create({ + name: "commandGhostText", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: pluginKey, + state: { + init: () => ({ ghost: null, dismissedAt: null }), + apply: (tr, prev, _old, next) => { + const meta = tr.getMeta(pluginKey) as GhostMeta | undefined; + + if (meta?.type === "reset") { + return { ghost: null, dismissedAt: null }; + } + + const ghost = computeGhost(sessionId, next); + + if (meta?.type === "dismiss") { + return { ghost: null, dismissedAt: ghost?.slashPos ?? null }; + } + + const suppressed = + prev.dismissedAt !== null && + ghost?.slashPos === prev.dismissedAt; + + if (suppressed) { + return { ghost: null, dismissedAt: prev.dismissedAt }; + } + + return { ghost, dismissedAt: null }; + }, + }, + props: { + decorations(state) { + const ghost = getGhost(state); + if (!ghost) return null; + + const remainder = ghost.item.label.slice(ghost.query.length); + if (!remainder) return null; + + return DecorationSet.create(state.doc, [ + Decoration.widget( + ghost.cursorPos, + createGhostWidget(remainder), + { + side: 1, + key: "command-ghost", + }, + ), + ]); + }, + handleKeyDown(view, event) { + if (!getGhost(view.state)) return false; + + if (event.key === "Tab") { + event.preventDefault(); + return acceptGhost(view.state, view.dispatch); + } + + if (event.key === "Escape") { + event.preventDefault(); + view.dispatch( + view.state.tr.setMeta(pluginKey, { + type: "dismiss", + } satisfies GhostMeta), + ); + return true; + } + + return false; + }, + }, + }), + ]; + }, + }); +} diff --git a/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts b/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts index 8d48b654a..0568e04e5 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts @@ -19,6 +19,11 @@ export interface SuggestionMentionConfig { debounceMs?: number; items: (query: string) => T[] | Promise; renderItem?: (item: T) => ReactNode; + /** + * When true, commit the suggestion as soon as the typed query exactly matches + * an item's label and no other item label extends it. + */ + autoCommit?: boolean; /** Override the chip attrs inserted for a given item. */ resolveChipAttrs?: (item: T) => Partial; /** Fires after the chip is inserted into the document. */ @@ -37,6 +42,7 @@ export function createSuggestionMention( debounceMs = 0, items: loadItems, renderItem, + autoCommit = false, resolveChipAttrs, onAfterInsert, } = config; @@ -118,6 +124,23 @@ export function createSuggestionMention( getReferenceClientRect: props.clientRect as () => DOMRect, }); } + + if (autoCommit) { + // Caveat: if one item label is a strict prefix of another (e.g. + // "add" vs "add-dir"), the shorter name becomes uncommittable via + // auto-commit and the user has to pick from the list. Avoid + // shipping prefix-clashing command names, or rename to disambiguate. + const q = props.query.toLowerCase(); + const exact = props.items.find((i) => i.label.toLowerCase() === q); + const hasLongerExtension = props.items.some( + (i) => + i.label.toLowerCase().startsWith(q) && + i.label.length > q.length, + ); + if (exact && !hasLongerExtension) { + props.command(exact); + } + } }, onKeyDown: (props) => {