diff --git a/apps/desktop/src/lib/markdown-code.test.ts b/apps/desktop/src/lib/markdown-code.test.ts index f71f564c1c2ab..79c5d091beb3c 100644 --- a/apps/desktop/src/lib/markdown-code.test.ts +++ b/apps/desktop/src/lib/markdown-code.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { isLikelyProseCodeBlock } from './markdown-code' +import { isLikelyProseCodeBlock, isLikelyProseFence, isLikelyStructuredText } from './markdown-code' describe('isLikelyProseCodeBlock', () => { it('detects prose that Streamdown mislabels as an unknown language', () => { @@ -20,4 +20,78 @@ describe('isLikelyProseCodeBlock', () => { it('keeps real code blocks', () => { expect(isLikelyProseCodeBlock('ts', 'const value = { bunny: true };\nreturn value')).toBe(false) }) + + it('keeps an SSH config block fenced (regression: rendered as flat prose)', () => { + const ssh = ['Host 192.168.0.159', ' HostName 192.168.0.159', ' User teknium', ' Port 22'].join('\n') + + expect(isLikelyProseCodeBlock('', ssh)).toBe(false) + expect(isLikelyProseCodeBlock('text', ssh)).toBe(false) + }) + + it('keeps a flat key-value config fenced', () => { + expect(isLikelyProseCodeBlock('', ['Host myserver', 'User teknium', 'Port 22'].join('\n'))).toBe(false) + }) + + it('keeps an .env-style dump fenced', () => { + expect(isLikelyProseCodeBlock('', ['API_KEY=abc123', 'PORT=8080', 'DEBUG=true'].join('\n'))).toBe(false) + }) +}) + +describe('isLikelyStructuredText', () => { + it('flags indented config stanzas', () => { + expect( + isLikelyStructuredText(['Host x', ' HostName 10.0.0.1', ' Port 22'].join('\n')) + ).toBe(true) + }) + + it('flags flat key-value / settings listings', () => { + expect(isLikelyStructuredText(['Host myserver', 'User teknium', 'Port 22'].join('\n'))).toBe(true) + expect(isLikelyStructuredText(['API_KEY=abc123', 'PORT=8080', 'DEBUG=true'].join('\n'))).toBe(true) + }) + + it('does NOT flag real wrapped prose', () => { + expect( + isLikelyStructuredText( + [ + 'This is the first sentence of a paragraph.', + 'Here is a second line that continues the thought.', + 'And a third concluding line follows here.' + ].join('\n') + ) + ).toBe(false) + }) + + it('does NOT flag prose without a config shape', () => { + expect( + isLikelyStructuredText( + ['the quick brown fox jumps', 'over the lazy sleeping dog', 'while the sun sets slowly'].join('\n') + ) + ).toBe(false) + }) + + it('ignores single-line blocks', () => { + expect(isLikelyStructuredText('Port 22')).toBe(false) + }) +}) + +describe('isLikelyProseFence', () => { + it('keeps an SSH config block fenced', () => { + const ssh = ['Host 192.168.0.159', ' HostName 192.168.0.159', ' User teknium', ' Port 22'].join('\n') + + expect(isLikelyProseFence('', ssh)).toBe(false) + expect(isLikelyProseFence('text', ssh)).toBe(false) + }) + + it('still unwraps a plain-language paragraph fence', () => { + expect( + isLikelyProseFence( + '', + [ + 'This is the first sentence of a paragraph.', + 'Here is a second line that continues the thought.', + 'And a third concluding line follows here.' + ].join('\n') + ) + ).toBe(true) + }) }) diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts index 4b1632b986034..ccad28422873d 100644 --- a/apps/desktop/src/lib/markdown-code.ts +++ b/apps/desktop/src/lib/markdown-code.ts @@ -273,6 +273,69 @@ function codeSignals(body: string): CodeSignals { } } +// A sentence-ending punctuation mark followed by whitespace or end-of-line. +// Real wrapped prose has these; config/structured listings almost never do. +const SENTENCE_PUNCTUATION_RE = /[.!?](?:\s|$)/ +// `Key: value` / `Key = value` — a settings/directive line with an explicit +// separator. Unambiguous config shape. +const CONFIG_SEPARATOR_LINE_RE = /^[A-Za-z0-9_][\w.-]*\s*[:=]\s*\S/ +// A bare identifier that could be a config key (`Host`, `Port`, `HostName`, +// `API_KEY`). Used only for the short `Key value` directive form below. +const CONFIG_KEY_RE = /^[A-Za-z0-9_][\w.-]*$/ + +// True when a single line looks like a config directive rather than prose. +// Either an explicit `Key: value` / `Key = value`, or a short whitespace +// directive of 2-3 tokens led by an identifier (`Host example`, `Port 22`, +// `HostName 10.0.0.1`). The token cap is what separates it from prose — a +// real sentence line has more words than a config directive, so a +// punctuation-less prose fragment like `the quick brown fox jumps` (5 tokens) +// is NOT treated as config. +function isConfigDirectiveLine(line: string): boolean { + const trimmed = line.trim() + + if (CONFIG_SEPARATOR_LINE_RE.test(trimmed)) { + return true + } + + const tokens = trimmed.split(/\s+/) + + return tokens.length >= 2 && tokens.length <= 3 && CONFIG_KEY_RE.test(tokens[0]) +} + +/** + * True when a fenced block looks like structured / config / tabular text + * rather than wrapped prose. Such blocks (SSH config, .env dumps, INI-style + * settings, key/value listings) trip the prose heuristics' "3+ plain lines, + * no JS/SQL tokens" rule and get their fence stripped — rendering as a flat + * paragraph instead of a code block. This veto keeps them fenced. + * + * Two signals, biased toward keeping the fence when ambiguous: + * - ANY indented continuation line (leading whitespace before content): + * prose is not indented line-by-line, but config stanzas are + * (`Host x` / ` HostName y`). + * - No sentence-ending punctuation AND a majority of lines are + * `Key value` / `Key: value` directives (see isConfigDirectiveLine). + */ +export function isLikelyStructuredText(body: string): boolean { + const lines = body.split('\n').filter(line => line.trim()) + + if (lines.length < 2) { + return false + } + + if (lines.some(line => /^\s+\S/.test(line))) { + return true + } + + if (lines.some(line => SENTENCE_PUNCTUATION_RE.test(line.trim()))) { + return false + } + + const configLines = lines.filter(line => isConfigDirectiveLine(line)).length + + return configLines >= Math.max(2, Math.ceil(lines.length * 0.6)) +} + export function isLikelyProseFence(info: string, body: string): boolean { const trimmedInfo = info.trim() const rawInfo = trimmedInfo.toLowerCase() @@ -302,6 +365,13 @@ export function isLikelyProseFence(info: string, body: string): boolean { return false } + // Config / key-value / indented listings are not prose — keep their fence + // so an SSH config, .env, or INI block renders as a code block instead of + // being unwrapped into a paragraph. + if (isLikelyStructuredText(body)) { + return false + } + return ( (signals.bulletLines >= 2 && signals.hasMarkdown && signals.codeSignals <= 2) || (signals.proseLines >= 3 && signals.codeSignals === 0) @@ -316,10 +386,19 @@ export function isLikelyProseCodeBlock(language: string | undefined, code: strin return false } + // A bullet list with markdown emphasis is prose even when it happens to be + // structured; the config veto below is only meant to protect config/kv + // listings, so let the bullet-prose case win first. if (signals.bulletLines >= 1 && (signals.hasMarkdown || signals.proseLines >= 2)) { return true } + // Config / key-value / indented listings are code, not prose — never + // unwrap them (SSH config, .env, INI, key/value tables). + if (isLikelyStructuredText(code || '')) { + return false + } + if (NON_CODE_FENCE_LANGUAGES.has(cleanLanguage)) { return signals.proseLines >= 3 && signals.codeSignals === 0 }