From 97c2da2112f7f46527dbc48ef8325df962dbf759 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Apr 2026 17:11:54 -0500 Subject: [PATCH] fix(tui): render MEDIA: as a clickable file chip, drop audio directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent emits `MEDIA:` 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:` → `▸ ` 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). --- ui-tui/src/__tests__/markdown.test.ts | 24 +++++++++++++++++++- ui-tui/src/components/markdown.tsx | 32 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index 236b4f961..478cb6255 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index cd0da465d..ebb3425a7 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -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,})>|~~(.+?)~~|\`([^\\\`]+)\`|\\*\\*(.+?)\\*\\*|(? + {'▸ '} + + + {path} + + + + ) + i++ + + continue + } + const fence = parseFence(line) if (fence) {