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')