Skip to content
292 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The chat composer formats as you write: a bar for bold, italic, strikethrough, links, lists, quotes, code and code blocks that can be shown or hidden, shortcuts listed under its keyboard button (Ctrl or Cmd with B, I, Shift+X, K, Shift+C, Alt+Shift+C, Shift+7, 8 and 9), and Markdown typed inline (*bold*, _italic_, ~strike~, `code`, ``` blocks, - and 1. lists, > quotes) turning into its formatting. Enter sends, Shift+Enter starts a line, Enter adds a list item or a code line where it should, Ctrl+Enter sends from anywhere, pasting a link over words links them, and plain text pasted in is read as Markdown. Mentions, emoji completion, drafts and typing work as before, messages are still sent and kept as Markdown, the editor loads only with the composer, and your own messages are edited in place with the same composer on a computer (Edit message in the message's menu) and in the sheet on a phone.1/**
2 * The chat composer's document and the Markdown a message is stored as,
3 * each turned into the other. The composer (components/chat/editor.tsx)
4 * edits a small rich-text document; what it sends, keeps as a draft and
5 * edits again is Markdown, the same text the API, agents and notifications
6 * read. Pure, on the editor's JSON, so it is tested without a browser
7 * (chat-compose.test.ts).
8 *
9 * In the document each paragraph is one line of the message: Shift+Enter
10 * starts a new one, an empty one is a blank line.
11 */
12import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown";
13
14/** The editor's JSON, as much of it as is read here. */
15export type DocNode = {
16 type: string;
17 attrs?: Record<string, unknown>;
18 content?: DocNode[];
19 text?: string;
20 marks?: { type: string; attrs?: Record<string, unknown> }[];
21};
22
23type Mark = { type: string; attrs?: Record<string, unknown> };
24
25// ---------------------------------------------------------------------------
26// Markdown to the document.
27
28function textNode(text: string, marks: Mark[]): DocNode | null {
29 if (!text) return null;
30 return marks.length ? { type: "text", text, marks } : { type: "text", text };
31}
32
33function spanNodes(list: Span[], marks: Mark[] = []): DocNode[] {
34 const out: DocNode[] = [];
35 const add = (node: DocNode | null) => node && out.push(node);
36 for (const span of list) {
37 switch (span.t) {
38 case "text":
39 add(textNode(span.v, marks));
40 break;
41 case "code":
42 add(textNode(span.v, [...marks, { type: "code" }]));
43 break;
44 case "strong":
45 out.push(...spanNodes(span.c, [...marks, { type: "bold" }]));
46 break;
47 case "em":
48 out.push(...spanNodes(span.c, [...marks, { type: "italic" }]));
49 break;
50 case "del":
51 out.push(...spanNodes(span.c, [...marks, { type: "strike" }]));
52 break;
53 case "link":
54 out.push(...spanNodes(span.c, [{ type: "link", attrs: { href: span.href } }, ...marks]));
55 break;
56 case "mention":
57 add(textNode(`@${span.name}`, marks));
58 break;
59 case "channel":
60 add(textNode(`#${span.name}`, marks));
61 break;
62 case "ref":
63 add(textNode(`${span.repo ?? ""}#${span.number}`, marks));
64 break;
65 }
66 }
67 return out;
68}
69
70function paragraph(spans: Span[]): DocNode {
71 const content = spanNodes(spans);
72 return content.length ? { type: "paragraph", content } : { type: "paragraph" };
73}
74
75function blockNodes(list: Block[]): DocNode[] {
76 const out: DocNode[] = [];
77 list.forEach((block, index) => {
78 // Paragraphs one after another were apart by a blank line: an empty line between them.
79 if (block.t === "p" && index > 0 && list[index - 1]!.t === "p") out.push({ type: "paragraph" });
80 switch (block.t) {
81 case "p":
82 for (const line of block.lines) out.push(paragraph(line));
83 break;
84 case "heading":
85 // Chat has no headings to write; one comes back as a bold line.
86 out.push(paragraph([{ t: "strong", c: block.c }]));
87 break;
88 case "hr":
89 break;
90 case "code":
91 out.push({ type: "codeBlock", attrs: { language: block.lang }, ...(block.v ? { content: [{ type: "text", text: block.v }] } : {}) });
92 break;
93 case "quote": {
94 const content = blockNodes(block.c);
95 out.push({ type: "blockquote", content: content.length ? content : [{ type: "paragraph" }] });
96 break;
97 }
98 case "list":
99 out.push({
100 type: block.ordered ? "orderedList" : "bulletList",
101 ...(block.ordered ? { attrs: { start: block.start } } : {}),
102 content: block.items.map((item) => {
103 const content = blockNodes(item);
104 // An item starts with a line of text.
105 if (content[0]?.type !== "paragraph") content.unshift({ type: "paragraph" });
106 return { type: "listItem", content };
107 }),
108 });
109 break;
110 }
111 });
112 return out;
113}
114
115/** A message's Markdown as the composer's document. */
116export function markdownToDoc(markdown: string): DocNode {
117 const content = blockNodes(blocks(markdown));
118 return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] };
119}
120
121// ---------------------------------------------------------------------------
122// The document to Markdown.
123
124/** Marks from the outside in: a link holds bold, bold holds code. */
125const ORDER = ["link", "bold", "italic", "strike", "code"];
126const OPEN: Record<string, string> = { bold: "**", italic: "_", strike: "~~" };
127
128function sameMark(a: Mark, b: Mark): boolean {
129 return a.type === b.type && (a.type !== "link" || a.attrs?.href === b.attrs?.href);
130}
131
132/** Text as Markdown shows it as typed: its marks escaped. */
133export function escapeText(text: string): string {
134 return text
135 .replace(/\\(?=[\\`*_{}[\]()#+\-.!~>|<=:@])/g, "\\\\")
136 .replace(/[`*~[\]]/g, (char) => `\\${char}`)
137 .replace(/_/g, (char, at: number, all: string) => {
138 // `snake_case` stays as typed: only an `_` at a word's edge could start or end italics.
139 const before = all[at - 1] ?? "";
140 const after = all[at + 1] ?? "";
141 return /\w/.test(before) && /\w/.test(after) ? char : `\\${char}`;
142 });
143}
144
145/** A line that would read as a block's start (`# `, `- `, `1. `, `> `, `---`) when it is only text. */
146function escapeLineStart(line: string): string {
147 return line
148 .replace(/^(\s*)(#{1,6}(?:\s|$))/, "$1\\$2")
149 .replace(/^(\s*)([-+](?:\s|$))/, "$1\\$2")
150 .replace(/^(\s*)(\d{1,9})([.)])(\s|$)/, "$1$2\\$3$4")
151 .replace(/^(\s*)(>)/, "$1\\$2")
152 .replace(/^(\s*)([-_=]{3,}\s*)$/, "$1\\$2")
153 .replace(/^(\s*)•/, "$1\\•");
154}
155
156function codeSpan(text: string): string {
157 if (!text.includes("`")) return `\`${text}\``;
158 return `\`\` ${text} \`\``;
159}
160
161function linkTarget(href: string): string {
162 return /[\s()<>]/.test(href) ? `<${href.replace(/[<>\s]/g, encodeURIComponent)}>` : href;
163}
164
165/** A line of inline content (text with marks) as Markdown. */
166function inlineMarkdown(nodes: DocNode[]): string {
167 let out = "";
168 const open: { mark: Mark; text: string }[] = [];
169 // A link's text is gathered so a bare URL can be written bare.
170 const close = (count: number) => {
171 for (let n = 0; n < count; n++) {
172 const top = open.pop()!;
173 // Spaces at a mark's end go after its closing mark: `**a **` would not be bold.
174 const body = top.text;
175 const trailing = /\s*$/.exec(body)![0];
176 const inner = body.slice(0, body.length - trailing.length);
177 let written: string;
178 if (top.mark.type === "link") {
179 const href = String(top.mark.attrs?.href ?? "");
180 written = inner === escapeText(href) && /^https?:\/\//i.test(href) && !/[\s()<>]/.test(href) ? href : `[${inner}](${linkTarget(href)})`;
181 } else if (!inner) {
182 written = "";
183 } else {
184 const delimiter = OPEN[top.mark.type] ?? "";
185 written = `${delimiter}${inner}${delimiter}`;
186 }
187 append(written + trailing);
188 }
189 };
190 const append = (text: string) => {
191 if (open.length) open[open.length - 1]!.text += text;
192 else out += text;
193 };
194 for (const node of nodes) {
195 if (node.type === "hardBreak") {
196 close(open.length);
197 out += "\n";
198 continue;
199 }
200 if (node.type !== "text" || !node.text) continue;
201 const marks = [...(node.marks ?? [])]
202 .filter((mark) => ORDER.includes(mark.type))
203 .sort((a, b) => ORDER.indexOf(a.type) - ORDER.indexOf(b.type));
204 // Close what this text no longer has, and everything opened after it.
205 let keep = 0;
206 while (keep < open.length && keep < marks.length && sameMark(open[keep]!.mark, marks[keep]!)) keep++;
207 close(open.length - keep);
208 const code = marks.some((mark) => mark.type === "code");
209 let text = node.text;
210 // Spaces before a mark's start go before it: `** a**` would not be bold.
211 const opening = marks.slice(keep).filter((mark) => mark.type !== "code");
212 if (opening.length) {
213 const leading = /^\s*/.exec(text)![0];
214 append(leading);
215 text = text.slice(leading.length);
216 }
217 for (const mark of marks.slice(keep)) if (mark.type !== "code") open.push({ mark, text: "" });
218 append(code ? codeSpan(text) : escapeText(text));
219 }
220 close(open.length);
221 return out;
222}
223
224function prefixLines(text: string, first: string, rest: string): string {
225 return text
226 .split("\n")
227 .map((line, index) => (index === 0 ? first : line ? rest : rest.trimEnd()) + line)
228 .join("\n");
229}
230
231function blockMarkdown(node: DocNode): string | null {
232 switch (node.type) {
233 case "paragraph": {
234 const line = inlineMarkdown(node.content ?? []);
235 return line
236 .split("\n")
237 .map((part) => escapeLineStart(part))
238 .join("\n");
239 }
240 case "codeBlock": {
241 const text = (node.content ?? []).map((child) => child.text ?? "").join("");
242 const longest = Math.max(2, ...(text.match(/`{3,}/g) ?? []).map((run) => run.length));
243 const fence = "`".repeat(longest + 1);
244 const language = typeof node.attrs?.language === "string" ? node.attrs.language : "";
245 return `${fence}${language}\n${text}\n${fence}`;
246 }
247 case "blockquote": {
248 const inner = blocksMarkdown(node.content ?? []);
249 return inner
250 .split("\n")
251 .map((line) => (line ? `> ${line}` : ">"))
252 .join("\n");
253 }
254 case "bulletList":
255 case "orderedList": {
256 const ordered = node.type === "orderedList";
257 const start = ordered ? Number(node.attrs?.start ?? 1) || 1 : 1;
258 return (node.content ?? [])
259 .map((item, index) => {
260 const marker = ordered ? `${start + index}. ` : "- ";
261 const inner = blocksMarkdown(item.content ?? [], true);
262 return prefixLines(inner, marker, " ".repeat(marker.length));
263 })
264 .join("\n");
265 }
266 default:
267 return null;
268 }
269}
270
271function blocksMarkdown(nodes: DocNode[], tight = false): string {
272 let out = "";
273 let previous: string | null = null;
274 for (const node of nodes) {
275 const text = blockMarkdown(node);
276 if (text == null) continue;
277 if (previous != null) {
278 // A line follows a line, and in a list item everything does; anything else stands apart by a blank line.
279 out += tight || (previous === "paragraph" && node.type === "paragraph") ? "\n" : "\n\n";
280 }
281 out += text;
282 previous = node.type;
283 }
284 return out;
285}
286
287/** The composer's document as the Markdown a message is sent as. */
288export function docToMarkdown(doc: DocNode): string {
289 return blocksMarkdown(doc.content ?? [])
290 .replace(/^(?:[ \t]*\n)+/, "")
291 .replace(/(?:\n[ \t]*)+$/, "");
292}