diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts
new file mode 100644
index 0000000000..478cb6255c
--- /dev/null
+++ b/ui-tui/src/__tests__/markdown.test.ts
@@ -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)
+ })
+})
diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx
index 5e1063837b..ebb3425a76 100644
--- a/ui-tui/src/components/markdown.tsx
+++ b/ui-tui/src/components/markdown.tsx
@@ -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,})>|~~(.+?)~~|\`([^\\\`]+)\`|\\*\\*(.+?)\\*\\*|(? {
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(/(?
+ {'▸ '}
+
+
+ {path}
+
+
+
+ )
+ i++
+
+ continue
+ }
+
const fence = parseFence(line)
if (fence) {