From e4d07cf93772611d8c3c19faadabd6045965a6bf Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 5 Nov 2026 14:28:37 -0800
Subject: [PATCH] Keep prices readable in chat (#38651)
* fix(chat): keep shell paths and prices as written
* Use the common inline dollar rule in chat
---
.../NativeChatMessageRow.fixture.tsx | 40 +++++++++++++++
.../sidebar/CommentMarkdown.dollars.test.tsx | 50 +++++++++++++++++++
.../components/sidebar/chat-markdown-math.ts | 33 ++++++++----
3 files changed, 113 insertions(+), 10 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/CommentMarkdown.dollars.test.tsx
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.fixture.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.fixture.tsx
index 0ff37b72124..a9f46fa48e1 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageRow.fixture.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.fixture.tsx
@@ -53,3 +53,43 @@ export const rejected = defineFixture({
width: 480,
render: () =>
})
+
+export const dollars = defineFixture({
+ width: 640,
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+
+ )
+})
diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.dollars.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.dollars.test.tsx
new file mode 100644
index 00000000000..025e2d291bd
--- /dev/null
+++ b/src/renderer/src/components/sidebar/CommentMarkdown.dollars.test.tsx
@@ -0,0 +1,50 @@
+// @vitest-environment happy-dom
+import { cleanup, render, waitFor } from '@testing-library/react'
+import { afterEach, beforeAll, expect, it, vi } from 'vitest'
+import CommentMarkdown from './CommentMarkdown'
+
+vi.mock('./CommentMermaidBlock', () => ({ default: () => null }))
+afterEach(cleanup)
+
+beforeAll(async () => {
+ const view = render()
+ await waitFor(() => expect(view.container.querySelector('.katex')).not.toBeNull())
+ view.unmount()
+})
+
+it.each([false, true])('preserves prices and code (growing=%s)', (growing) => {
+ const source = [
+ 'Set $ORCA_HOME then run echo $PATH; $HOME and $PATH stay literal.',
+ 'It costs $5 or $10; $5 + tax$ is the cost.',
+ 'Run `echo $HOME/bin:$PATH` and `$VAR`.'
+ ].join('\n\n')
+ const view = render(
+
+ )
+ expect(view.container.querySelector('.katex, .katex-error')).toBeNull()
+ expect(Array.from(view.container.querySelectorAll('p'), (node) => node.textContent)).toEqual([
+ 'Set $ORCA_HOME then run echo $PATH; $HOME and $PATH stay literal.',
+ 'It costs $5 or $10; $5 + tax$ is the cost.',
+ 'Run echo $HOME/bin:$PATH and $VAR.'
+ ])
+ expect(Array.from(view.container.querySelectorAll('code'), (node) => node.textContent)).toEqual([
+ 'echo $HOME/bin:$PATH',
+ '$VAR'
+ ])
+
+ view.rerender(
+
+ )
+ expect(
+ Array.from(view.container.querySelectorAll('annotation'), (node) => node.textContent)
+ ).toEqual(['x^2', 'n', 'y', '5+2', '5 + 2', '5 + 2', '!x', 'x', '\\$3 + x', 'x^*', '"x"'])
+ expect(view.container.textContent).toContain('Costs $5 + tax, but ')
+ expect(view.container.textContent).toContain('Keep $5 + 2$ typed; render ')
+})
diff --git a/src/renderer/src/components/sidebar/chat-markdown-math.ts b/src/renderer/src/components/sidebar/chat-markdown-math.ts
index 966dd33d5cc..d0f207d0091 100644
--- a/src/renderer/src/components/sidebar/chat-markdown-math.ts
+++ b/src/renderer/src/components/sidebar/chat-markdown-math.ts
@@ -28,13 +28,15 @@ declare module 'micromark-util-types' {
const lineEnding = (code: Code): boolean => code !== null && code < -2
const whitespace = (code: Code): boolean => code === null || code < 0 || code === 32
-const digit = (code: Code): boolean => code !== null && code >= 48 && code <= 57
+const digit = (code: Code): code is number => code !== null && code >= 48 && code <= 57
+const currencyTerminators = new Set([33, 41, 58, 59, 63])
type ExhaustedSearch = { from: number; to: number; last: Map }
function construct(flow: boolean): Construct {
const searches = new WeakMap>()
const tokenize: Tokenizer = function (effects, ok, nok) {
+ const afterWhitespace = () => whitespace(this.previous)
const knowledge = flow ? undefined : (searches.get(this) ?? new Map())
if (knowledge) {
searches.set(this, knowledge)
@@ -48,6 +50,7 @@ function construct(flow: boolean): Construct {
let escaped = false
let candidate: Token
let closeSize = 0
+ let closeAfterWhitespace = false
const continuation: Construct = {
partial: true,
tokenize(e, yes, no) {
@@ -126,8 +129,23 @@ function construct(flow: boolean): Construct {
if (opener === '$' && whitespace(code)) {
return nok(code)
}
+ if (opener === '$' && digit(code)) {
+ effects.enter('chatMathData')
+ return leadingNumber(code)
+ }
return between(code)
}
+ function leadingNumber(code: Code): State | undefined {
+ if (digit(code) || code === 44 || code === 46) {
+ effects.consume(code)
+ body += String.fromCharCode(code)
+ return leadingNumber
+ }
+ if (whitespace(code) || (code !== null && currencyTerminators.has(code))) {
+ return nok(code)
+ }
+ return data(code)
+ }
function between(code: Code): State | undefined {
if (code === null) {
return flow ? finish(code) : exhausted(code)
@@ -136,7 +154,7 @@ function construct(flow: boolean): Construct {
if (flow) {
return effects.attempt(continuation, continued, finish)(code)
}
- if (opener === '$' || opener === '\\(') {
+ if (opener === '\\(') {
return exhausted(code)
}
effects.enter('lineEnding')
@@ -146,6 +164,7 @@ function construct(flow: boolean): Construct {
return between
}
if (code === 36 && opener.startsWith('$')) {
+ closeAfterWhitespace = afterWhitespace()
candidate = effects.enter('chatMathMarker')
closeSize = 0
return dollarClose(code)
@@ -160,7 +179,7 @@ function construct(flow: boolean): Construct {
return between(code)
}
function dollarClose(code: Code): State | undefined {
- if (code === 36) {
+ if (code === 36 && (opener !== '$' || closeSize === 0)) {
effects.consume(code)
closeSize++
return dollarClose
@@ -174,13 +193,7 @@ function construct(flow: boolean): Construct {
body += '$'.repeat(closeSize)
return between(code)
}
- if (
- opener === '$' &&
- (/\s/.test(body.at(-1) ?? '') ||
- digit(code) ||
- (/^[+-]?\d[\d,]*(?:\.\d+)?(?:[^\p{ASCII}]|\s)/u.test(body) &&
- !/[\\_^=<>+*/{}-]/.test(body)))
- ) {
+ if (opener === '$' && (closeAfterWhitespace || digit(code))) {
return nok(code)
}
effects.exit('chatMathMarker')