mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge pull request #13204 from NousResearch/bb/tui-markdown-intraword-underscore
fix(tui): markdown — guard intraword underscores + clean protocol sentinels
This commit is contained in:
commit
f859e8d88a
2 changed files with 93 additions and 5 deletions
56
ui-tui/src/__tests__/markdown.test.ts
Normal file
56
ui-tui/src/__tests__/markdown.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AUDIO_DIRECTIVE_RE, INLINE_RE, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
|
||||
|
||||
const matches = (text: string) => [...text.matchAll(INLINE_RE)].map(m => m[0])
|
||||
|
||||
describe('INLINE_RE emphasis', () => {
|
||||
it('matches word-boundary italic/bold', () => {
|
||||
expect(matches('say _hi_ there')).toEqual(['_hi_'])
|
||||
expect(matches('very __bold__ move')).toEqual(['__bold__'])
|
||||
expect(matches('(_paren_) and [_bracket_]')).toEqual(['_paren_', '_bracket_'])
|
||||
})
|
||||
|
||||
it('keeps intraword underscores literal', () => {
|
||||
const path = '/home/me/.hermes/cache/screenshots/browser_screenshot_ecc1c3feab.png'
|
||||
|
||||
expect(matches(path)).toEqual([])
|
||||
expect(matches('snake_case_var and MY_CONST')).toEqual([])
|
||||
expect(matches('foo__bar__baz')).toEqual([])
|
||||
})
|
||||
|
||||
it('still matches asterisk emphasis intraword', () => {
|
||||
expect(matches('a*b*c')).toEqual(['*b*'])
|
||||
expect(matches('a**bold**c')).toEqual(['**bold**'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripInlineMarkup', () => {
|
||||
it('strips word-boundary emphasis only', () => {
|
||||
expect(stripInlineMarkup('say _hi_ there')).toBe('say hi there')
|
||||
expect(stripInlineMarkup('browser_screenshot_ecc.png')).toBe('browser_screenshot_ecc.png')
|
||||
expect(stripInlineMarkup('__bold__ and foo__bar__')).toBe('bold and foo__bar__')
|
||||
})
|
||||
})
|
||||
|
||||
describe('protocol sentinels', () => {
|
||||
it('captures MEDIA: paths with surrounding quotes or backticks', () => {
|
||||
expect('MEDIA:/tmp/a.png'.match(MEDIA_LINE_RE)?.[1]).toBe('/tmp/a.png')
|
||||
expect(' MEDIA: /home/me/.hermes/cache/screenshots/browser_screenshot_ecc.png '.match(MEDIA_LINE_RE)?.[1]).toBe(
|
||||
'/home/me/.hermes/cache/screenshots/browser_screenshot_ecc.png'
|
||||
)
|
||||
expect('`MEDIA:/tmp/a.png`'.match(MEDIA_LINE_RE)?.[1]).toBe('/tmp/a.png')
|
||||
expect('"MEDIA:C:\\files\\a.png"'.match(MEDIA_LINE_RE)?.[1]).toBe('C:\\files\\a.png')
|
||||
})
|
||||
|
||||
it('ignores MEDIA: tokens embedded in prose', () => {
|
||||
expect('here is MEDIA:/tmp/a.png for you'.match(MEDIA_LINE_RE)).toBeNull()
|
||||
expect('the media: section is empty'.match(MEDIA_LINE_RE)).toBeNull()
|
||||
})
|
||||
|
||||
it('matches the [[audio_as_voice]] directive', () => {
|
||||
expect(AUDIO_DIRECTIVE_RE.test('[[audio_as_voice]]')).toBe(true)
|
||||
expect(AUDIO_DIRECTIVE_RE.test(' [[audio_as_voice]] ')).toBe(true)
|
||||
expect(AUDIO_DIRECTIVE_RE.test('audio_as_voice')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -12,8 +12,11 @@ const DEF_RE = /^\s*:\s+(.+)$/
|
|||
const TABLE_DIVIDER_CELL_RE = /^:?-{3,}:?$/
|
||||
const MD_URL_RE = '((?:[^\\s()]|\\([^\\s()]*\\))+?)'
|
||||
|
||||
const INLINE_RE = new RegExp(
|
||||
`(!\\[(.*?)\\]\\(${MD_URL_RE}\\)|\\[(.+?)\\]\\(${MD_URL_RE}\\)|<((?:https?:\\/\\/|mailto:)[^>\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>|~~(.+?)~~|\`([^\\\`]+)\`|\\*\\*(.+?)\\*\\*|__(.+?)__|\\*(.+?)\\*|_(.+?)_|==(.+?)==|\\[\\^([^\\]]+)\\]|\\^([^^\\s][^^]*?)\\^|~([^~\\s][^~]*?)~|(https?:\\/\\/[^\\s<]+))`,
|
||||
export const MEDIA_LINE_RE = /^\s*[`"']?MEDIA:\s*(\S+?)[`"']?\s*$/
|
||||
export const AUDIO_DIRECTIVE_RE = /^\s*\[\[audio_as_voice\]\]\s*$/
|
||||
|
||||
export const INLINE_RE = new RegExp(
|
||||
`(!\\[(.*?)\\]\\(${MD_URL_RE}\\)|\\[(.+?)\\]\\(${MD_URL_RE}\\)|<((?:https?:\\/\\/|mailto:)[^>\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>|~~(.+?)~~|\`([^\\\`]+)\`|\\*\\*(.+?)\\*\\*|(?<!\\w)__(.+?)__(?!\\w)|\\*(.+?)\\*|(?<!\\w)_(.+?)_(?!\\w)|==(.+?)==|\\[\\^([^\\]]+)\\]|\\^([^^\\s][^^]*?)\\^|~([^~\\s][^~]*?)~|(https?:\\/\\/[^\\s<]+))`,
|
||||
'g'
|
||||
)
|
||||
|
||||
|
|
@ -90,7 +93,7 @@ const isTableDivider = (row: string) => {
|
|||
return cells.length > 1 && cells.every(cell => TABLE_DIVIDER_CELL_RE.test(cell))
|
||||
}
|
||||
|
||||
const stripInlineMarkup = (value: string) =>
|
||||
export const stripInlineMarkup = (value: string) =>
|
||||
value
|
||||
.replace(/!\[(.*?)\]\(((?:[^\s()]|\([^\s()]*\))+?)\)/g, '[image: $1] $2')
|
||||
.replace(/\[(.+?)\]\(((?:[^\s()]|\([^\s()]*\))+?)\)/g, '$1')
|
||||
|
|
@ -98,9 +101,9 @@ const stripInlineMarkup = (value: string) =>
|
|||
.replace(/~~(.+?)~~/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/__(.+?)__/g, '$1')
|
||||
.replace(/(?<!\w)__(.+?)__(?!\w)/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/_(.+?)_/g, '$1')
|
||||
.replace(/(?<!\w)_(.+?)_(?!\w)/g, '$1')
|
||||
.replace(/==(.+?)==/g, '$1')
|
||||
.replace(/\[\^([^\]]+)\]/g, '[$1]')
|
||||
.replace(/\^([^^\s][^^]*?)\^/g, '^$1')
|
||||
|
|
@ -267,6 +270,35 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
continue
|
||||
}
|
||||
|
||||
if (AUDIO_DIRECTIVE_RE.test(line)) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const media = line.match(MEDIA_LINE_RE)
|
||||
|
||||
if (media) {
|
||||
start('paragraph')
|
||||
|
||||
const path = media[1]!
|
||||
const url = /^(?:\/|[a-z]:[\\/])/i.test(path) ? `file://${path}` : path
|
||||
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{'▸ '}
|
||||
<Link url={url}>
|
||||
<Text color={t.color.amber} underline>
|
||||
{path}
|
||||
</Text>
|
||||
</Link>
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const fence = parseFence(line)
|
||||
|
||||
if (fence) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue