mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
Keep prices readable in chat (#38651)
* fix(chat): keep shell paths and prices as written * Use the common inline dollar rule in chat
This commit is contained in:
@@ -53,3 +53,43 @@ export const rejected = defineFixture({
|
||||
width: 480,
|
||||
render: () => <RecordedSteer state="rejected" />
|
||||
})
|
||||
|
||||
export const dollars = defineFixture({
|
||||
width: 640,
|
||||
render: () => (
|
||||
<div className="native-chat-appearance space-y-4 p-4">
|
||||
<MessageRow
|
||||
message={{
|
||||
id: 'dollar-question',
|
||||
role: 'user',
|
||||
timestamp: 0,
|
||||
source: 'transcript',
|
||||
blocks: [{ type: 'text', text: 'Can I use `$HOME/bin:$PATH`? It costs $5 or $10.' }]
|
||||
}}
|
||||
expandSignal={false}
|
||||
onScrollMessageToTop={() => {}}
|
||||
/>
|
||||
<MessageRow
|
||||
message={{
|
||||
id: 'dollar-answer',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'text',
|
||||
text: [
|
||||
'Set $ORCA_HOME then run echo $PATH. Use `$HOME/bin:$PATH` or `$HOME/${PATH}`. In PowerShell, use `$env:PATH;$env:TEMP`.',
|
||||
'Prices: $5 + tax$ stays as written. Inline code stays literal: `echo $HOME`.',
|
||||
String.raw`Real math: the $n$th term, $x^2$, $5+2$, and \(5 + 2\).`,
|
||||
'$$5 + 2$$'
|
||||
].join('\n\n')
|
||||
}
|
||||
]
|
||||
}}
|
||||
expandSignal={false}
|
||||
onScrollMessageToTop={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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(<CommentMarkdown content="$x$" math />)
|
||||
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(
|
||||
<CommentMarkdown content={source} math growing={growing} variant="document" />
|
||||
)
|
||||
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(
|
||||
<CommentMarkdown
|
||||
content={String.raw`Use $x^2$ for the $n$th term. Costs $5 + tax, but $y$ is math. Keep $5 + 2$ typed; render $5+2$, \(5 + 2\), and $$5 + 2$$.
|
||||
|
||||
Costs $5, and f(x)=$!x$. Cost $5 + tax$x$ applies. Keep $\$3 + x$, $x^*$s, and $"x"$s as math.`}
|
||||
math
|
||||
growing={growing}
|
||||
variant="document"
|
||||
/>
|
||||
)
|
||||
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 ')
|
||||
})
|
||||
@@ -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<number, number> }
|
||||
|
||||
function construct(flow: boolean): Construct {
|
||||
const searches = new WeakMap<TokenizeContext, Map<string, ExhaustedSearch>>()
|
||||
const tokenize: Tokenizer = function (effects, ok, nok) {
|
||||
const afterWhitespace = () => whitespace(this.previous)
|
||||
const knowledge = flow ? undefined : (searches.get(this) ?? new Map<string, ExhaustedSearch>())
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user