Skip to content

Commit 7f1c1e2

Browse files
authored
feat: mid message commands (#1701)
Closes #1456 [Screen Recording 2026-04-17 at 11.51.58.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.com/user-attachments/thumbnails/28a88a0d-9603-405e-937a-18f5637b2d0b.mov" />](https://app.graphite.com/user-attachments/video/28a88a0d-9603-405e-937a-18f5637b2d0b.mov)
1 parent 7a93b1d commit 7f1c1e2

2 files changed

Lines changed: 178 additions & 0 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { Extension } from "@tiptap/core";
2+
import type { EditorState, Transaction } from "@tiptap/pm/state";
3+
import { Plugin, PluginKey } from "@tiptap/pm/state";
4+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
5+
import { getCommandSuggestions } from "../suggestions/getSuggestions";
6+
import type { CommandSuggestionItem } from "../types";
7+
8+
interface GhostMatch {
9+
slashPos: number;
10+
cursorPos: number;
11+
query: string;
12+
item: CommandSuggestionItem;
13+
}
14+
15+
interface PluginState {
16+
ghost: GhostMatch | null;
17+
dismissedAt: number | null;
18+
}
19+
20+
type GhostMeta = { type: "dismiss" } | { type: "reset" };
21+
22+
const pluginKey = new PluginKey<PluginState>("commandGhostText");
23+
const SLASH_QUERY_REGEX = /(?:^|\s)\/([^\s/]+)$/;
24+
25+
const getGhost = (state: EditorState): GhostMatch | null =>
26+
pluginKey.getState(state)?.ghost ?? null;
27+
28+
function computeGhost(
29+
sessionId: string,
30+
state: EditorState,
31+
): GhostMatch | null {
32+
if (!sessionId) return null;
33+
const { selection } = state;
34+
if (!selection.empty) return null;
35+
36+
const $from = selection.$from;
37+
const textBeforeCursor = $from.parent.textBetween(
38+
0,
39+
$from.parentOffset,
40+
"\n",
41+
"\uFFFC",
42+
);
43+
44+
const match = SLASH_QUERY_REGEX.exec(textBeforeCursor);
45+
if (!match) return null;
46+
47+
const query = match[1];
48+
const slashPos =
49+
$from.start() + match.index + (match[0].length - query.length - 1);
50+
51+
if (state.doc.resolve(slashPos).parentOffset === 0) return null;
52+
53+
const top = getCommandSuggestions(sessionId, query)[0];
54+
if (!top) return null;
55+
56+
const lowerLabel = top.label.toLowerCase();
57+
const lowerQuery = query.toLowerCase();
58+
if (!lowerLabel.startsWith(lowerQuery) || lowerLabel === lowerQuery) {
59+
return null;
60+
}
61+
62+
return { slashPos, cursorPos: selection.from, query, item: top };
63+
}
64+
65+
function createGhostWidget(text: string): HTMLElement {
66+
const span = document.createElement("span");
67+
span.textContent = text;
68+
span.className = "cli-command-ghost pointer-events-none text-[var(--gray-9)]";
69+
return span;
70+
}
71+
72+
function acceptGhost(
73+
state: EditorState,
74+
dispatch: (tr: Transaction) => void,
75+
): boolean {
76+
const ghost = getGhost(state);
77+
if (!ghost) return false;
78+
79+
const chipType = state.schema.nodes.mentionChip;
80+
if (!chipType) return false;
81+
82+
const chip = chipType.create({
83+
type: "command",
84+
id: ghost.item.id,
85+
label: ghost.item.label,
86+
pastedText: false,
87+
});
88+
const space = state.schema.text(" ");
89+
90+
dispatch(
91+
state.tr
92+
.replaceWith(ghost.slashPos, ghost.cursorPos, [chip, space])
93+
.setMeta(pluginKey, { type: "reset" } satisfies GhostMeta),
94+
);
95+
return true;
96+
}
97+
98+
export function createCommandGhostText(sessionId: string) {
99+
return Extension.create({
100+
name: "commandGhostText",
101+
102+
addProseMirrorPlugins() {
103+
return [
104+
new Plugin<PluginState>({
105+
key: pluginKey,
106+
state: {
107+
init: () => ({ ghost: null, dismissedAt: null }),
108+
apply: (tr, prev, _old, next) => {
109+
const meta = tr.getMeta(pluginKey) as GhostMeta | undefined;
110+
111+
if (meta?.type === "reset") {
112+
return { ghost: null, dismissedAt: null };
113+
}
114+
115+
const ghost = computeGhost(sessionId, next);
116+
117+
if (meta?.type === "dismiss") {
118+
return { ghost: null, dismissedAt: ghost?.slashPos ?? null };
119+
}
120+
121+
const suppressed =
122+
prev.dismissedAt !== null &&
123+
ghost?.slashPos === prev.dismissedAt;
124+
125+
if (suppressed) {
126+
return { ghost: null, dismissedAt: prev.dismissedAt };
127+
}
128+
129+
return { ghost, dismissedAt: null };
130+
},
131+
},
132+
props: {
133+
decorations(state) {
134+
const ghost = getGhost(state);
135+
if (!ghost) return null;
136+
137+
const remainder = ghost.item.label.slice(ghost.query.length);
138+
if (!remainder) return null;
139+
140+
return DecorationSet.create(state.doc, [
141+
Decoration.widget(
142+
ghost.cursorPos,
143+
createGhostWidget(remainder),
144+
{
145+
side: 1,
146+
key: "command-ghost",
147+
},
148+
),
149+
]);
150+
},
151+
handleKeyDown(view, event) {
152+
if (!getGhost(view.state)) return false;
153+
154+
if (event.key === "Tab") {
155+
event.preventDefault();
156+
return acceptGhost(view.state, view.dispatch);
157+
}
158+
159+
if (event.key === "Escape") {
160+
event.preventDefault();
161+
view.dispatch(
162+
view.state.tr.setMeta(pluginKey, {
163+
type: "dismiss",
164+
} satisfies GhostMeta),
165+
);
166+
return true;
167+
}
168+
169+
return false;
170+
},
171+
},
172+
}),
173+
];
174+
},
175+
});
176+
}

apps/code/src/renderer/features/message-editor/tiptap/extensions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Placeholder from "@tiptap/extension-placeholder";
22
import StarterKit from "@tiptap/starter-kit";
3+
import { createCommandGhostText } from "./CommandGhostText";
34
import { createCommandMention } from "./CommandMention";
45
import { createFileMention } from "./FileMention";
56
import { MentionChipNode } from "./MentionChipNode";
@@ -43,6 +44,7 @@ export function getEditorExtensions(options: EditorExtensionsOptions) {
4344

4445
if (commands) {
4546
extensions.push(createCommandMention({ sessionId }));
47+
extensions.push(createCommandGhostText(sessionId));
4648
}
4749

4850
return extensions;

0 commit comments

Comments
 (0)