fix(tui): render MEDIA: as a clickable file chip, drop audio directive

The agent emits `MEDIA:<path>` to signal file delivery to the gateway,
and `[[audio_as_voice]]` as a voice-delivery hint. The gateway strips
both before sending to Telegram/Discord/Slack, but the TUI was rendering
them raw through markdown — which is also how the intraword underscore
bug originally surfaced (`browser_screenshot_ecc…`).

At the `Md` layer, detect both sentinels on their own line:
- `MEDIA:<path>` → `▸ <path>` with the path rendered literal and wrapped
  in a `Link` for OSC 8 hyperlink support (absolute paths get a
  `file://` URL, so modern terminals make them click-to-open).
- `[[audio_as_voice]]` → dropped silently; it has no meaning in TUI.

Covers tests for quoted/backticked MEDIA variants, Windows drive paths,
whitespace, and the inline-in-prose case (left untouched — still
protected by the intraword-underscore guard).
This commit is contained in:
Brooklyn Nicholson 2026-04-20 17:11:54 -05:00
parent b17eb94907
commit 97c2da2112
2 changed files with 55 additions and 1 deletions

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { INLINE_RE, stripInlineMarkup } from '../components/markdown.js'
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])
@ -32,3 +32,25 @@ describe('stripInlineMarkup', () => {
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)
})
})

View file

@ -12,6 +12,9 @@ const DEF_RE = /^\s*:\s+(.+)$/
const TABLE_DIVIDER_CELL_RE = /^:?-{3,}:?$/
const MD_URL_RE = '((?:[^\\s()]|\\([^\\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'
@ -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) {