pi-ui-extend 1.0.36 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,10 +28,15 @@ export function renderConversationEntry(entry, width, options) {
28
28
  const lines = renderMarkdownTextLines(userEntry.text, userContentWidth, userContentLeft).map((line) => ({
29
29
  ...userLine(line.text, userEntry.id, line.syntaxHighlight, [
30
30
  ...(line.segments ?? []),
31
+ ...diagramStyledSegments(line.diagramSegments, options.colors),
31
32
  ...(line.links?.map((link) => ({ ...link, foreground: options.colors.info, underline: true })) ?? []),
32
33
  ], line.links),
33
34
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
34
35
  ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
36
+ ...(line.codeBlock ? {
37
+ colorOverride: options.colors.codeBlockForeground,
38
+ backgroundOverride: options.colors.codeBlockBackground,
39
+ } : {}),
35
40
  }));
36
41
  return attachImageClickTargets(lines, userEntry.id, userEntry.images, { foreground: options.colors.info, underline: true });
37
42
  };
@@ -94,13 +99,16 @@ function renderAssistantLines(text, width, options) {
94
99
  const existingSegments = line.segments?.map((segment) => ({ ...segment, start: segment.start + contentLeft, end: segment.end + contentLeft })) ?? [];
95
100
  const links = line.links?.map((link) => ({ ...link, start: link.start + contentLeft, end: link.end + contentLeft })) ?? [];
96
101
  const linkSegments = links.map((link) => ({ start: link.start, end: link.end, foreground: options.colors.info, underline: true }));
97
- const allSegments = headingSegment ? [headingSegment, ...existingSegments, ...linkSegments] : [...existingSegments, ...linkSegments];
102
+ const diagramSegments = diagramStyledSegments(line.diagramSegments, options.colors, contentLeft);
103
+ const allSegments = headingSegment
104
+ ? [headingSegment, ...existingSegments, ...diagramSegments, ...linkSegments]
105
+ : [...existingSegments, ...diagramSegments, ...linkSegments];
98
106
  lines.push({
99
107
  text: padHorizontalText(line.text, width),
100
108
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
101
109
  ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
102
- colorOverride: options.colors.assistantForeground,
103
- backgroundOverride: options.colors.assistantMessageBackground,
110
+ colorOverride: line.codeBlock ? options.colors.codeBlockForeground : options.colors.assistantForeground,
111
+ backgroundOverride: line.codeBlock ? options.colors.codeBlockBackground : options.colors.assistantMessageBackground,
104
112
  ...(allSegments.length > 0 ? { segments: allSegments } : {}),
105
113
  ...(links.length > 0 ? { links } : {}),
106
114
  ...(line.syntaxHighlight ? { syntaxHighlight: line.syntaxHighlight } : {}),
@@ -108,3 +116,17 @@ function renderAssistantLines(text, width, options) {
108
116
  }
109
117
  return lines;
110
118
  }
119
+ function diagramStyledSegments(segments, colors, offset = 0) {
120
+ return (segments ?? []).map((segment) => {
121
+ const range = { start: segment.start + offset, end: segment.end + offset };
122
+ switch (segment.class) {
123
+ case "border":
124
+ case "edgeLabel":
125
+ return { ...range, foreground: colors.muted };
126
+ case "edge":
127
+ return { ...range, foreground: colors.accent };
128
+ case "title":
129
+ return { ...range, foreground: colors.accent, bold: true };
130
+ }
131
+ });
132
+ }
@@ -1,3 +1,4 @@
1
+ import { type Cls as MermaidSpanClass } from "grok-mermaid";
1
2
  import { type SyntaxLineHighlight, type ToolBodySyntaxHighlights } from "./syntax-highlight.js";
2
3
  export type RenderedMarkdownLine = {
3
4
  text: string;
@@ -22,6 +23,7 @@ export type RenderedMarkdownTextLine = {
22
23
  text: string;
23
24
  copyText?: string;
24
25
  continuesOnNextLine?: boolean;
26
+ codeBlock?: true;
25
27
  segments?: readonly {
26
28
  start: number;
27
29
  end: number;
@@ -29,8 +31,14 @@ export type RenderedMarkdownTextLine = {
29
31
  }[] | undefined;
30
32
  links?: readonly RenderedMarkdownLink[] | undefined;
31
33
  syntaxHighlight?: SyntaxLineHighlight | undefined;
34
+ diagramSegments?: readonly RenderedMarkdownDiagramSegment[] | undefined;
32
35
  heading?: boolean;
33
36
  };
37
+ export type RenderedMarkdownDiagramSegment = {
38
+ start: number;
39
+ end: number;
40
+ class: Exclude<MermaidSpanClass, "none" | "text">;
41
+ };
34
42
  export type RenderMarkdownTextLinesOptions = {
35
43
  preserveWrappedWordSeparator?: boolean;
36
44
  };
@@ -1,6 +1,12 @@
1
+ import { render as renderMermaid } from "grok-mermaid";
1
2
  import { displayGraphemes, expandTabs, stringDisplayWidth } from "./terminal-width.js";
2
3
  import { syntaxHighlightLanguageForMarkdownFence, } from "./syntax-highlight.js";
3
4
  const MIN_TRAILING_WORD_WIDTH_TO_REBALANCE = 5;
5
+ const MERMAID_CACHE_LIMIT = 100;
6
+ const MERMAID_MAX_PANELS = 4;
7
+ const MERMAID_MIN_PANEL_WIDTH = 40;
8
+ const MERMAID_SEQUENCE_MESSAGE_OPERATORS = ["-->>", "->>", "--x", "-x", "--)", "-)", "-->", "->"];
9
+ const mermaidRenderCache = new Map();
4
10
  export function formatMarkdownTables(text, maxWidth) {
5
11
  const lines = text.split("\n");
6
12
  const formatted = [];
@@ -85,14 +91,40 @@ export function renderMarkdownLine(text, start = 0) {
85
91
  export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
86
92
  const lines = [];
87
93
  let fence;
94
+ let fenceHasBodyLine = false;
88
95
  const formattedText = formatMarkdownTables(sanitizeMarkdownText(text), width);
89
96
  if (formattedText.length === 0)
90
97
  return [];
91
- for (const rawLine of formattedText.split("\n")) {
98
+ const rawLines = formattedText.split("\n");
99
+ for (let lineIndex = 0; lineIndex < rawLines.length;) {
100
+ const rawLine = rawLines[lineIndex] ?? "";
92
101
  const nextFence = markdownFence(rawLine);
102
+ if (!fence && nextFence && isMermaidFence(nextFence)) {
103
+ const diagram = renderMermaidBlock(rawLines, lineIndex, nextFence, width);
104
+ if (diagram) {
105
+ lines.push(...diagram.lines);
106
+ lineIndex = diagram.nextLineIndex;
107
+ continue;
108
+ }
109
+ }
93
110
  const closesFence = Boolean(fence && nextFence && fence.marker === nextFence.marker && nextFence.length >= fence.length);
94
111
  const opensFence = !fence && nextFence !== undefined;
95
- const syntaxHighlight = markdownLineSyntaxHighlight(fence, Boolean(opensFence || closesFence), start);
112
+ if (opensFence && nextFence) {
113
+ fence = { ...nextFence, language: syntaxHighlightLanguageForMarkdownFence(nextFence.info) };
114
+ fenceHasBodyLine = false;
115
+ lineIndex += 1;
116
+ continue;
117
+ }
118
+ if (closesFence) {
119
+ if (!fenceHasBodyLine)
120
+ lines.push({ text: "", codeBlock: true });
121
+ fence = undefined;
122
+ fenceHasBodyLine = false;
123
+ lineIndex += 1;
124
+ continue;
125
+ }
126
+ const syntaxHighlight = markdownLineSyntaxHighlight(fence, false, start);
127
+ const codeBlock = fence !== undefined;
96
128
  const isHeadingLine = !fence && /^\s{0,3}#{1,6}\s/.test(rawLine);
97
129
  const markdownLine = syntaxHighlight?.language === "markdown" || isHeadingLine ? renderMarkdownLine(rawLine) : undefined;
98
130
  const logicalLine = markdownLine ?? { text: rawLine, segments: [], links: [] };
@@ -113,21 +145,245 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
113
145
  text: wrapped.text,
114
146
  ...(wrapped.copyText === undefined ? {} : { copyText: wrapped.copyText }),
115
147
  ...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
148
+ ...(codeBlock ? { codeBlock: true } : {}),
116
149
  ...(wrapped.segments.length > 0 ? { segments: wrapped.segments } : {}),
117
150
  ...(wrapped.links && wrapped.links.length > 0 ? { links: wrapped.links } : {}),
118
151
  ...(wrappedSyntaxHighlight ? { syntaxHighlight: wrappedSyntaxHighlight } : {}),
119
152
  ...(isHeadingLine ? { heading: true } : {}),
120
153
  });
121
154
  }
122
- if (opensFence && nextFence) {
123
- fence = { ...nextFence, language: syntaxHighlightLanguageForMarkdownFence(nextFence.info) };
155
+ if (codeBlock)
156
+ fenceHasBodyLine = true;
157
+ lineIndex += 1;
158
+ }
159
+ if (fence && !fenceHasBodyLine)
160
+ lines.push({ text: "", codeBlock: true });
161
+ return lines;
162
+ }
163
+ function renderMermaidBlock(rawLines, openingLineIndex, openingFence, width) {
164
+ let closingLineIndex;
165
+ for (let index = openingLineIndex + 1; index < rawLines.length; index += 1) {
166
+ const candidate = markdownFence(rawLines[index] ?? "");
167
+ if (candidate && candidate.marker === openingFence.marker && candidate.length >= openingFence.length) {
168
+ closingLineIndex = index;
169
+ break;
124
170
  }
125
- else if (closesFence) {
126
- fence = undefined;
171
+ }
172
+ const sourceEnd = closingLineIndex ?? rawLines.length;
173
+ const source = rawLines.slice(openingLineIndex + 1, sourceEnd).join("\n");
174
+ let art = cachedMermaidRender(source);
175
+ if (!art)
176
+ return undefined;
177
+ const availableWidth = Math.max(1, width);
178
+ let participantLegend = [];
179
+ let messageLegend = [];
180
+ if (art.width > availableWidth) {
181
+ const compactSequence = compactMermaidSequence(source);
182
+ if (compactSequence) {
183
+ const compactArt = cachedMermaidRender(compactSequence.source);
184
+ if (compactArt && compactArt.width < art.width) {
185
+ art = compactArt;
186
+ participantLegend = compactSequence.participantLegend;
187
+ messageLegend = compactSequence.messageLegend;
188
+ }
189
+ }
190
+ }
191
+ const panelStarts = mermaidPanelStarts(art.width, availableWidth);
192
+ if (panelStarts.length > 1 && (availableWidth < MERMAID_MIN_PANEL_WIDTH || panelStarts.length > MERMAID_MAX_PANELS)) {
193
+ return undefined;
194
+ }
195
+ return {
196
+ lines: [
197
+ ...mermaidArtLines(art, availableWidth, panelStarts),
198
+ ...mermaidLegendLines("Participants", participantLegend, availableWidth),
199
+ ...mermaidLegendLines("Messages", messageLegend, availableWidth),
200
+ ],
201
+ nextLineIndex: closingLineIndex === undefined ? rawLines.length : closingLineIndex + 1,
202
+ };
203
+ }
204
+ function compactMermaidSequence(source) {
205
+ const lines = source.split("\n");
206
+ const header = lines.find((line) => line.trim().length > 0 && !line.trimStart().startsWith("%%"));
207
+ if (!header || !/^sequenceDiagram\s*$/iu.test(header.trim()))
208
+ return undefined;
209
+ const participantLegend = [];
210
+ const messageLegend = [];
211
+ const autonumber = lines.some((line) => /^\s*autonumber(?:\s|$)/iu.test(line));
212
+ let participantNumber = 0;
213
+ let messageNumber = 0;
214
+ const compactLines = lines.map((line) => {
215
+ const match = /^(\s*)(participant|actor)\s+(\S+?)(?:\s+as\s+(.+?))?\s*$/iu.exec(line);
216
+ if (match) {
217
+ const [, indent = "", declaration = "participant", id = "", rawLabel] = match;
218
+ participantNumber += 1;
219
+ const marker = `P${participantNumber}`;
220
+ const label = rawLabel ? normalizeMermaidLegendLabel(rawLabel) : id;
221
+ participantLegend.push(`${marker} (${id}) — ${label}`);
222
+ return `${indent}${declaration} ${id} as ${marker}`;
223
+ }
224
+ const message = mermaidSequenceMessage(line);
225
+ if (!message)
226
+ return line;
227
+ messageNumber += 1;
228
+ if (message.label.length === 0)
229
+ return line;
230
+ messageLegend.push(`${messageNumber}. ${normalizeMermaidLegendLabel(message.label)}`);
231
+ return autonumber ? message.prefix : `${message.prefix}: [${messageNumber}]`;
232
+ });
233
+ return participantLegend.length > 0 || messageLegend.length > 0
234
+ ? { source: compactLines.join("\n"), participantLegend, messageLegend }
235
+ : undefined;
236
+ }
237
+ function mermaidSequenceMessage(line) {
238
+ for (const operator of MERMAID_SEQUENCE_MESSAGE_OPERATORS) {
239
+ const operatorIndex = line.indexOf(operator);
240
+ if (operatorIndex < 0)
241
+ continue;
242
+ const colonIndex = line.indexOf(":", operatorIndex + operator.length);
243
+ const targetEnd = colonIndex < 0 ? line.length : colonIndex;
244
+ const from = line.slice(0, operatorIndex).trim();
245
+ const to = line.slice(operatorIndex + operator.length, targetEnd).trim();
246
+ if (!from || !to || /\s/u.test(from) || /\s/u.test(to))
247
+ return undefined;
248
+ return {
249
+ prefix: line.slice(0, targetEnd).trimEnd(),
250
+ label: colonIndex < 0 ? "" : line.slice(colonIndex + 1).trim(),
251
+ };
252
+ }
253
+ return undefined;
254
+ }
255
+ function normalizeMermaidLegendLabel(label) {
256
+ const normalized = label
257
+ .replace(/<br\s*\/?\s*>/giu, " ")
258
+ .replace(/<\/?[A-Za-z][^>]*>/gu, "")
259
+ .replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, "$1$2")
260
+ .trim();
261
+ return normalized || label.trim();
262
+ }
263
+ function mermaidPanelStarts(artWidth, panelWidth) {
264
+ if (artWidth <= panelWidth)
265
+ return [0];
266
+ const overlap = Math.min(24, Math.max(8, Math.floor(panelWidth / 5)));
267
+ const stride = Math.max(1, panelWidth - overlap);
268
+ const panelCount = 1 + Math.ceil((artWidth - panelWidth) / stride);
269
+ return Array.from({ length: panelCount }, (_, index) => Math.min(index * stride, artWidth - panelWidth));
270
+ }
271
+ function mermaidArtLines(art, width, panelStarts) {
272
+ if (panelStarts.length === 1) {
273
+ return art.plain.map((text, rowIndex) => {
274
+ const diagramSegments = mermaidDiagramSegments(text, art.styled[rowIndex] ?? []);
275
+ return { text, ...(diagramSegments.length > 0 ? { diagramSegments } : {}) };
276
+ });
277
+ }
278
+ const lines = [];
279
+ for (const [panelIndex, startColumn] of panelStarts.entries()) {
280
+ const continuesLeft = startColumn > 0;
281
+ const continuesRight = startColumn + width < art.width;
282
+ const title = `Mermaid ${panelIndex + 1}/${panelStarts.length}${continuesLeft ? " ←" : ""}${continuesRight ? " →" : ""}`;
283
+ lines.push({
284
+ text: title,
285
+ diagramSegments: [{ start: 0, end: title.length, class: "title" }],
286
+ });
287
+ for (const [rowIndex, text] of art.plain.entries()) {
288
+ lines.push(sliceMermaidDiagramRow(text, art.styled[rowIndex] ?? [], startColumn, width));
289
+ }
290
+ }
291
+ return lines;
292
+ }
293
+ function mermaidLegendLines(title, entries, width) {
294
+ if (entries.length === 0)
295
+ return [];
296
+ const lines = [{
297
+ text: title,
298
+ diagramSegments: [{ start: 0, end: title.length, class: "title" }],
299
+ }];
300
+ for (const entry of entries) {
301
+ for (const wrapped of wrapRenderedMarkdownLine({ text: entry, segments: [], links: [] }, width, {})) {
302
+ lines.push({
303
+ text: wrapped.text,
304
+ ...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
305
+ });
127
306
  }
128
307
  }
129
308
  return lines;
130
309
  }
310
+ function sliceMermaidDiagramRow(text, spans, startColumn, width) {
311
+ const endColumn = startColumn + width;
312
+ const diagramSegments = [];
313
+ let result = "";
314
+ let column = 0;
315
+ let spanIndex = 0;
316
+ let spanEnd = spans[0]?.text.length ?? 0;
317
+ for (const grapheme of displayGraphemes(text)) {
318
+ const nextColumn = column + grapheme.width;
319
+ if (nextColumn <= startColumn) {
320
+ column = nextColumn;
321
+ continue;
322
+ }
323
+ if (column >= endColumn)
324
+ break;
325
+ while (spanIndex < spans.length - 1 && grapheme.start >= spanEnd) {
326
+ spanIndex += 1;
327
+ spanEnd += spans[spanIndex]?.text.length ?? 0;
328
+ }
329
+ const fullyVisible = column >= startColumn && nextColumn <= endColumn;
330
+ const chunk = fullyVisible
331
+ ? grapheme.text
332
+ : " ".repeat(Math.max(0, Math.min(nextColumn, endColumn) - Math.max(column, startColumn)));
333
+ const chunkStart = result.length;
334
+ result += chunk;
335
+ const spanClass = spans[spanIndex]?.cls;
336
+ if (fullyVisible && spanClass && spanClass !== "none" && spanClass !== "text") {
337
+ const previous = diagramSegments.at(-1);
338
+ if (previous && previous.class === spanClass && previous.end === chunkStart) {
339
+ previous.end = result.length;
340
+ }
341
+ else {
342
+ diagramSegments.push({ start: chunkStart, end: result.length, class: spanClass });
343
+ }
344
+ }
345
+ column = nextColumn;
346
+ }
347
+ return { text: result, ...(diagramSegments.length > 0 ? { diagramSegments } : {}) };
348
+ }
349
+ function cachedMermaidRender(source) {
350
+ if (mermaidRenderCache.has(source)) {
351
+ const cached = mermaidRenderCache.get(source) ?? null;
352
+ mermaidRenderCache.delete(source);
353
+ mermaidRenderCache.set(source, cached);
354
+ return cached;
355
+ }
356
+ let art;
357
+ try {
358
+ art = renderMermaid(source.replace(/<br\s*\/?\s*>/giu, " "));
359
+ }
360
+ catch {
361
+ art = null;
362
+ }
363
+ mermaidRenderCache.set(source, art);
364
+ if (mermaidRenderCache.size > MERMAID_CACHE_LIMIT) {
365
+ const oldestKey = mermaidRenderCache.keys().next().value;
366
+ if (oldestKey !== undefined)
367
+ mermaidRenderCache.delete(oldestKey);
368
+ }
369
+ return art;
370
+ }
371
+ function mermaidDiagramSegments(text, spans) {
372
+ const segments = [];
373
+ let start = 0;
374
+ for (const span of spans) {
375
+ const end = start + span.text.length;
376
+ if (start < text.length && span.cls !== "none" && span.cls !== "text") {
377
+ segments.push({ start, end: Math.min(end, text.length), class: span.cls });
378
+ }
379
+ start = end;
380
+ }
381
+ return segments;
382
+ }
383
+ function isMermaidFence(fence) {
384
+ const token = fence.info.trim().split(/\s+/, 1)[0]?.toLowerCase().replace(/^[{.]+|[}.]+$/gu, "") ?? "";
385
+ return token === "mermaid";
386
+ }
131
387
  export function markdownSyntaxHighlightsForText(text, startColumn = 0) {
132
388
  const highlights = [];
133
389
  let fence;
package/dist/theme.d.ts CHANGED
@@ -18,6 +18,8 @@ export type Theme = {
18
18
  assistantMessageBackground: string;
19
19
  userMessageBackground: string;
20
20
  thinkingMessageBackground: string;
21
+ codeBlockForeground: string;
22
+ codeBlockBackground: string;
21
23
  inputCursorBackground: string;
22
24
  popupForeground: string;
23
25
  popupBackground: string;
package/dist/theme.js CHANGED
@@ -19,6 +19,8 @@ export const THEMES = {
19
19
  assistantMessageBackground: "",
20
20
  userMessageBackground: "",
21
21
  thinkingMessageBackground: "",
22
+ codeBlockForeground: "#e6edf3",
23
+ codeBlockBackground: "#2a2f36",
22
24
  inputCursorBackground: "#7fb3c8",
23
25
  popupForeground: "#e6edf3",
24
26
  popupBackground: "#1e1e1e",
@@ -75,6 +77,8 @@ export const THEMES = {
75
77
  assistantMessageBackground: "",
76
78
  userMessageBackground: "",
77
79
  thinkingMessageBackground: "",
80
+ codeBlockForeground: "#0f172a",
81
+ codeBlockBackground: "#f1f5f9",
78
82
  inputCursorBackground: "#0284c7",
79
83
  popupForeground: "#0f172a",
80
84
  popupBackground: "#ffffff",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.36",
3
+ "version": "1.0.37",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -82,6 +82,7 @@
82
82
  "@earendil-works/pi-coding-agent": "0.84.4",
83
83
  "@earendil-works/pi-tui": "0.84.4",
84
84
  "@mariozechner/clipboard": "^0.3.9",
85
+ "grok-mermaid": "0.2.2",
85
86
  "jsonc-parser": "3.3.1",
86
87
  "typebox": "1.1.38",
87
88
  "vscode-jsonrpc": "^8.2.1",