mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Merge pull request #74806 from NousResearch/bb/ref-parity
A sent reference renders as the chip the composer showed
This commit is contained in:
commit
466e6402f6
5 changed files with 118 additions and 14 deletions
|
|
@ -15,13 +15,15 @@ import {
|
|||
type SlashChipKind,
|
||||
slashIconElement
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
import { referenceKind } from '@/components/assistant-ui/reference-kinds'
|
||||
import { referenceKind, referenceRe } from '@/components/assistant-ui/reference-kinds'
|
||||
|
||||
import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
/** @see referenceRe — the shared pattern every surface recognises a reference
|
||||
* with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */
|
||||
export const REF_RE = referenceRe()
|
||||
|
||||
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import { useSessionLinkTitle } from '@/lib/session-link-title'
|
|||
import { parseSessionRefValue, sessionRefFallbackLabel } from '@/lib/session-refs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { referenceKind, referenceStyle } from './reference-kinds'
|
||||
import { referenceKind, referenceRe, referenceStyle, WIRE_REFERENCE_KINDS } from './reference-kinds'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
|
||||
const HERMES_REF_TYPES = WIRE_REFERENCE_KINDS
|
||||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||
|
||||
/** Icon glyphs come from the shared reference vocabulary, so the popover row
|
||||
|
|
@ -116,15 +116,7 @@ const DirectiveIcon: FC<{ type: string; className?: string }> = ({ type, classNa
|
|||
*/
|
||||
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
|
||||
|
||||
const HERMES_DIRECTIVE_RE = new RegExp(
|
||||
'@(file|folder|url|image|tool|line|terminal|session):(' +
|
||||
'`[^`\\n]+`' +
|
||||
'|"[^"\\n]+"' +
|
||||
"|'[^'\\n]+'" +
|
||||
'|\\S+' +
|
||||
')',
|
||||
'g'
|
||||
)
|
||||
const HERMES_DIRECTIVE_RE = referenceRe()
|
||||
|
||||
// A skill referenced in a sent message — either the invocation that opens it
|
||||
// (`/work fix the leak`, which is all a skill turn ever renders as) or one
|
||||
|
|
|
|||
|
|
@ -129,3 +129,31 @@ export function referenceKind(type: string | undefined): ReferenceKind {
|
|||
export function referenceStyle(type: string | undefined): ReferenceStyle {
|
||||
return REFERENCE_STYLES[referenceKind(type)]
|
||||
}
|
||||
|
||||
/**
|
||||
* The kinds that travel in message text as `@kind:value`. A subset of the table
|
||||
* above: `command`/`skill`/`theme` arrive via `/`, and `diff`/`staged`/`emoji`
|
||||
* have no value to carry.
|
||||
*/
|
||||
export const WIRE_REFERENCE_KINDS = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
|
||||
|
||||
/**
|
||||
* The one pattern that recognises a reference in text.
|
||||
*
|
||||
* A value is quoted whenever it needs to be — `@url:` always, and any path with
|
||||
* a space — so the quoted forms are tried BEFORE bare `\S+`, or a quoted value
|
||||
* would end at the first space and strand the rest as prose.
|
||||
*/
|
||||
const REFERENCE_PATTERN = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/
|
||||
|
||||
/**
|
||||
* A fresh matcher for every surface that has to find references in text: the
|
||||
* composer hydrating a draft, the sent bubble, the edit composer.
|
||||
*
|
||||
* New instance per call on purpose — a shared `/g` regex carries `lastIndex`
|
||||
* between callers, which is how a scanner silently skips the first reference in
|
||||
* the next string it's handed.
|
||||
*/
|
||||
export function referenceRe(): RegExp {
|
||||
return new RegExp(REFERENCE_PATTERN.source, 'g')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { referenceRe, WIRE_REFERENCE_KINDS } from '@/components/assistant-ui/reference-kinds'
|
||||
|
||||
import { UserMessageText } from './user-message-text'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/**
|
||||
* A sent reference must render as the chip the composer showed. These cover the
|
||||
* seam where that used to break: the value's quoting is directive syntax, and a
|
||||
* surface that reads it as markdown splits one reference into two wrong things.
|
||||
*/
|
||||
describe('a sent reference renders as the chip the composer showed', () => {
|
||||
it('chips a backtick-quoted @url: instead of splitting it into code', () => {
|
||||
render(<UserMessageText text="@url:`https://github.com/NousResearch/hermes-agent/pull/74790` urls lose formatting" />)
|
||||
|
||||
expect(screen.queryByTitle('https://github.com/NousResearch/hermes-agent/pull/74790')).not.toBeNull()
|
||||
// The whole reference is one node — no bare `@url:` text left behind.
|
||||
expect(document.body.textContent).not.toContain('@url:')
|
||||
})
|
||||
|
||||
it('chips a backtick-quoted @file: path with spaces', () => {
|
||||
render(<UserMessageText text="see @file:`apps/desktop/my notes.md` please" />)
|
||||
|
||||
expect(screen.queryByTitle('apps/desktop/my notes.md')).not.toBeNull()
|
||||
expect(document.body.textContent).not.toContain('@file:')
|
||||
})
|
||||
|
||||
it('chips every kind that travels in message text', () => {
|
||||
// The guard against WIRE_REFERENCE_KINDS and the pattern's own alternation
|
||||
// drifting apart: add a kind to one and this fails until both agree.
|
||||
for (const kind of WIRE_REFERENCE_KINDS) {
|
||||
expect(`@${kind}:\`some value\``.match(referenceRe()), kind).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('still renders a genuine code span as code', () => {
|
||||
render(<UserMessageText text="run `npm test` first" />)
|
||||
|
||||
const code = document.querySelector('[data-slot="aui_user-inline-code"]')
|
||||
|
||||
expect(code?.textContent).toBe('npm test')
|
||||
})
|
||||
|
||||
it('renders code and a reference side by side', () => {
|
||||
render(<UserMessageText text="run `npm test` on @file:`apps/desktop/a b.ts` now" />)
|
||||
|
||||
expect(document.querySelector('[data-slot="aui_user-inline-code"]')?.textContent).toBe('npm test')
|
||||
expect(screen.queryByTitle('apps/desktop/a b.ts')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a fenced block alone', () => {
|
||||
render(<UserMessageText text={'before\n```ts\nconst x = 1\n```\nafter'} />)
|
||||
|
||||
expect(document.querySelector('[data-slot="aui_user-fence"]')?.textContent).toBe('const x = 1\n')
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,7 @@ import type { FC } from 'react'
|
|||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { referenceRe } from '@/components/assistant-ui/reference-kinds'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// User messages should render the bare-minimum of markdown: backtick `code`
|
||||
|
|
@ -42,6 +43,28 @@ const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
|
|||
// Greedy backtick run length so ``code with `backticks` inside`` works.
|
||||
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
|
||||
|
||||
// A directive's value is BACKTICK-QUOTED whenever it needs to be (`@url:`
|
||||
// always, and any path with a space), so the inline-code scanner would claim
|
||||
// those backticks first and split one reference into a bare `@url:` plus a code
|
||||
// span — the composer's chip, flattened on send. Directives win: this is syntax
|
||||
// the composer wrote, not something the user typed as code.
|
||||
|
||||
/** Inline-code matches that don't overlap a directive, so a quoted directive
|
||||
* value reaches DirectiveContent whole. */
|
||||
function inlineCodeOutsideDirectives(text: string): RegExpMatchArray[] {
|
||||
const directives = Array.from(text.matchAll(referenceRe())).map(match => ({
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length
|
||||
}))
|
||||
|
||||
return Array.from(text.matchAll(INLINE_CODE_RE)).filter(match => {
|
||||
const start = match.index ?? 0
|
||||
const end = start + match[0].length
|
||||
|
||||
return !directives.some(directive => start < directive.end && end > directive.start)
|
||||
})
|
||||
}
|
||||
|
||||
function splitFences(text: string): TopSegment[] {
|
||||
const segments: TopSegment[] = []
|
||||
let cursor = 0
|
||||
|
|
@ -72,7 +95,7 @@ function splitInlineCode(text: string): InlineNode[] {
|
|||
const nodes: InlineNode[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(INLINE_CODE_RE)) {
|
||||
for (const match of inlineCodeOutsideDirectives(text)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue