mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73110 from NousResearch/bb/composer-at-chip
Chip @ file/folder refs instead of dropping them as plain text
This commit is contained in:
commit
0ae2997345
9 changed files with 263 additions and 22 deletions
|
|
@ -213,8 +213,12 @@ async def preprocess_context_references_async(
|
|||
f"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit})."
|
||||
)
|
||||
|
||||
stripped = _remove_reference_tokens(message, refs)
|
||||
final = stripped
|
||||
# Leave the `@file:`/`@folder:` tokens where the user typed them. The token
|
||||
# IS the reference, not scaffolding around it: clients render each one as an
|
||||
# inline chip, so stripping them left a sentence with a hole in it ("review
|
||||
# and ship") and made the desktop re-derive the refs from the attached block
|
||||
# to show them as a detached list above the prose.
|
||||
final = message
|
||||
if warnings:
|
||||
final = f"{final}\n\n--- Context Warnings ---\n" + "\n".join(f"- {warning}" for warning in warnings)
|
||||
if blocks:
|
||||
|
|
@ -473,19 +477,6 @@ def _parse_file_reference_value(value: str) -> tuple[str, int | None, int | None
|
|||
return _strip_reference_wrappers(value), None, None
|
||||
|
||||
|
||||
def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str:
|
||||
pieces: list[str] = []
|
||||
cursor = 0
|
||||
for ref in refs:
|
||||
pieces.append(message[cursor:ref.start])
|
||||
cursor = ref.end
|
||||
pieces.append(message[cursor:])
|
||||
text = "".join(pieces)
|
||||
text = re.sub(r"\s{2,}", " ", text)
|
||||
text = re.sub(r"\s+([,.;:!?])", r"\1", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _is_binary_file(path: Path) -> bool:
|
||||
mime, _ = mimetypes.guess_type(path.name)
|
||||
if mime and not mime.startswith("text/") and not any(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-qu
|
|||
|
||||
import { cloneAttachments, type QueueEditState } from '../composer-utils'
|
||||
import { onComposerSubmitRequest } from '../focus'
|
||||
import { pathifyRefs } from '../path-refs'
|
||||
import { composerPlainText } from '../rich-editor'
|
||||
import { useComposerScope } from '../scope'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
|
@ -139,7 +140,10 @@ export function useComposerSubmit({
|
|||
}
|
||||
}
|
||||
|
||||
const text = draftRef.current
|
||||
// A path that never got its committing space (`@apps/desktop/` left by a Tab
|
||||
// descend, then Enter) is still the reference the user picked — promote it
|
||||
// on the way out so it attaches instead of submitting as inert text.
|
||||
const text = pathifyRefs(draftRef.current)
|
||||
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||
|
||||
// A clarify card parked on this session owns the turn: the agent is blocked
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
|
|||
import { useComposerVoice } from './hooks/use-composer-voice'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useSessionStatusPresence } from './hooks/use-status-presence'
|
||||
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
|
|
@ -449,9 +450,9 @@ export function ChatBar({
|
|||
|
||||
// Links in the paste land as `@url:` chips rather than a wall of URL text —
|
||||
// the same reference the "Add URL" dialog inserts, parsed in place so a link
|
||||
// mid-sentence keeps its position.
|
||||
// mid-sentence keeps its position. Bare `@path` tokens promote the same way.
|
||||
recordUndoPoint()
|
||||
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
|
||||
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)))
|
||||
scheduleFlushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
|
|
@ -519,6 +520,16 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
// Same for a bare `@path` — a hand-typed or Tab-descended path chips into
|
||||
// the `@file:`/`@folder:` ref it means, instead of submitting as plain text
|
||||
// the backend never resolves.
|
||||
if (withUndoPoint(() => chipTypedPathOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
|
||||
// reserved for the global command palette.
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
|
|
|
|||
103
apps/desktop/src/app/chat/composer/path-refs.test.ts
Normal file
103
apps/desktop/src/app/chat/composer/path-refs.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type { KeyboardEvent } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
|
||||
import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
/**
|
||||
* Tab-descending into a folder from the `@` popover leaves a bare
|
||||
* `@apps/desktop/` in the editor. That is not a reference — the backend's
|
||||
* REFERENCE_PATTERN only matches `@kind:value` — so it used to submit as plain
|
||||
* text and silently attach nothing.
|
||||
*/
|
||||
|
||||
/** An editor holding `text` with a collapsed caret at its end, plus the space
|
||||
* keydown the composer would hand `chipTypedPathOnSpace`. */
|
||||
const spaceOn = (text: string) => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = text
|
||||
document.body.append(editor)
|
||||
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(editor.firstChild!, text.length)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent<HTMLDivElement> }
|
||||
}
|
||||
|
||||
describe('pathifyRefs', () => {
|
||||
it('promotes a Tab-descended folder token', () => {
|
||||
expect(pathifyRefs('look at @apps/desktop/')).toBe('look at @folder:`apps/desktop`')
|
||||
})
|
||||
|
||||
it('promotes a typed file path', () => {
|
||||
expect(pathifyRefs('read @src/main.ts please')).toBe('read @file:`src/main.ts` please')
|
||||
})
|
||||
|
||||
it('promotes every bare path in one message', () => {
|
||||
expect(pathifyRefs('@apps/desktop/ and @src/main.ts')).toBe('@folder:`apps/desktop` and @file:`src/main.ts`')
|
||||
})
|
||||
|
||||
it('leaves an already-typed directive alone', () => {
|
||||
const text = 'look at @folder:`apps/desktop` ok'
|
||||
|
||||
expect(pathifyRefs(text)).toBe(text)
|
||||
})
|
||||
|
||||
it('leaves a handle alone', () => {
|
||||
expect(pathifyRefs('cc @teknium1 on this')).toBe('cc @teknium1 on this')
|
||||
})
|
||||
|
||||
it('leaves simple refs alone', () => {
|
||||
expect(pathifyRefs('check @diff and @staged')).toBe('check @diff and @staged')
|
||||
})
|
||||
|
||||
it('leaves an email alone', () => {
|
||||
expect(pathifyRefs('mail me at brooklyn@nous.dev/x')).toBe('mail me at brooklyn@nous.dev/x')
|
||||
})
|
||||
|
||||
it('is a no-op without an @', () => {
|
||||
expect(pathifyRefs('nothing here')).toBe('nothing here')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chipTypedPathOnSpace', () => {
|
||||
it('chips a Tab-descended folder token into a real @folder: chip', () => {
|
||||
const { editor, event } = spaceOn('look at @apps/desktop/')
|
||||
|
||||
expect(chipTypedPathOnSpace(event)).toBe(true)
|
||||
|
||||
const chip = editor.querySelector('[data-ref-text]')
|
||||
|
||||
expect(chip?.getAttribute('data-ref-kind')).toBe('folder')
|
||||
expect(chip?.getAttribute('data-ref-id')).toBe('apps/desktop')
|
||||
expect(chip?.textContent).toContain('desktop')
|
||||
expect(composerPlainText(editor)).toBe('look at @folder:`apps/desktop` ')
|
||||
})
|
||||
|
||||
it('chips a typed file path', () => {
|
||||
const { editor, event } = spaceOn('read @src/main.ts')
|
||||
|
||||
expect(chipTypedPathOnSpace(event)).toBe(true)
|
||||
expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('read @file:`src/main.ts` ')
|
||||
})
|
||||
|
||||
it('leaves a handle alone', () => {
|
||||
const { editor, event } = spaceOn('cc @teknium1')
|
||||
|
||||
expect(chipTypedPathOnSpace(event)).toBe(false)
|
||||
expect(editor.querySelector('[data-ref-text]')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves an already-typed ref alone', () => {
|
||||
const { event } = spaceOn('look at @folder:`apps/desktop`')
|
||||
|
||||
expect(chipTypedPathOnSpace(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
103
apps/desktop/src/app/chat/composer/path-refs.ts
Normal file
103
apps/desktop/src/app/chat/composer/path-refs.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Bare-path recognition for the composer. Walking into a folder from the `@`
|
||||
* popover (Tab) re-types the token as a bare `@apps/desktop/` so the next
|
||||
* `complete.path` lists that folder's children. That's right while typing, but
|
||||
* a bare token is not a reference: `REFERENCE_PATTERN` only matches
|
||||
* `@kind:value`, so a draft sent with one attaches nothing and renders as
|
||||
* plain text instead of a chip.
|
||||
*
|
||||
* So the bare token is promoted to its typed form on the way out — the same
|
||||
* shape `url-refs.ts` uses for bare links.
|
||||
*/
|
||||
import type { KeyboardEvent } from 'react'
|
||||
|
||||
import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor'
|
||||
import { textBeforeCaret } from './text-utils'
|
||||
|
||||
// A `/` is required, exactly like URL_RE requires an explicit scheme: `@teknium1`
|
||||
// and `@diff` are a handle and a simple ref, not paths, and guessing wrong turns
|
||||
// someone's name into a file reference. A separator is the cheap signal that the
|
||||
// user meant a path. The token also can't start with a `:` kind prefix — that is
|
||||
// already a typed ref and REF_RE owns it.
|
||||
const BARE_PATH_RE = /(?<![\w@/])@((?!(?:file|folder|url|image|tool|line|terminal|session|git):)[^\s@:]*\/[^\s@:]*)/g
|
||||
const TYPED_BARE_PATH_RE = new RegExp(`${BARE_PATH_RE.source}$`)
|
||||
|
||||
/** Trailing `/` means a directory — that's how the gateway's completion emits
|
||||
* folders, and what Tab-descending leaves behind. */
|
||||
export function barePathRef(path: string) {
|
||||
const trimmed = path.replace(/\/+$/, '')
|
||||
|
||||
return trimmed ? `@${path.endsWith('/') ? 'folder' : 'file'}:${quoteRefValue(trimmed)}` : null
|
||||
}
|
||||
|
||||
/** Rewrite bare `@path` tokens as typed `@file:`/`@folder:` directives, leaving
|
||||
* anything already inside a directive alone. Returns `text` unchanged when
|
||||
* there are none. */
|
||||
export function pathifyRefs(text: string) {
|
||||
if (!text.includes('@')) {
|
||||
return text
|
||||
}
|
||||
|
||||
REF_RE.lastIndex = 0
|
||||
|
||||
const fenced = Array.from(text.matchAll(REF_RE)).map(match => {
|
||||
const start = match.index ?? 0
|
||||
|
||||
return { end: start + match[0].length, start }
|
||||
})
|
||||
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(BARE_PATH_RE)) {
|
||||
const start = match.index ?? 0
|
||||
const ref = barePathRef(match[1] || '')
|
||||
|
||||
if (!ref || fenced.some(span => start >= span.start && start < span.end)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out += `${text.slice(cursor, start)}${ref}`
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
return out + text.slice(cursor)
|
||||
}
|
||||
|
||||
/** A plain space finishing a typed `@path` commits it as a chip, so a hand-typed
|
||||
* path chips the same way a picked one does. Returns whether it ran, so a
|
||||
* keydown handler can fall through on anything else. */
|
||||
export function chipTypedPathOnSpace(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const editor = event.currentTarget
|
||||
|
||||
// Runs on every space, so bail on the cheap native read before paying for the
|
||||
// caret range walk (same guard shape as the trigger detector).
|
||||
if (!editor.textContent?.includes('@')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const match = before ? TYPED_BARE_PATH_RE.exec(before) : null
|
||||
const token = match?.[0]
|
||||
const ref = match?.[1] ? barePathRef(match[1]) : null
|
||||
|
||||
if (!token || !ref) {
|
||||
return false
|
||||
}
|
||||
|
||||
const directive = ref.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
if (!directive) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' '))
|
||||
|
||||
return replaceBeforeCaret(editor, token.length, fragment)
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
type InlineRefInput,
|
||||
insertInlineRefsIntoEditor
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
insertComposerContentsAtCaret,
|
||||
|
|
@ -543,7 +544,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
recordUndoPoint()
|
||||
|
||||
// Links land as `@url:` chips, same as the main composer.
|
||||
insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText))
|
||||
insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText)))
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
}
|
||||
|
||||
|
|
@ -685,6 +686,15 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
return
|
||||
}
|
||||
|
||||
// Same for a bare `@path`.
|
||||
if (withUndoPoint(() => chipTypedPathOnSpace(event))) {
|
||||
event.preventDefault()
|
||||
rememberInitialDraft()
|
||||
syncDraftFromEditor(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitEdit(event.currentTarget)
|
||||
|
|
|
|||
|
|
@ -220,6 +220,19 @@ describe('toChatMessages', () => {
|
|||
expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook')
|
||||
})
|
||||
|
||||
it('leaves an inline @ ref in place instead of hoisting a duplicate', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'summarize @file:`src/main.ts` for me\n\n--- Attached Context ---\n\n📄 @file:`src/main.ts` (10 tokens)\n```ts\nconst x = 1\n```',
|
||||
timestamp: 1
|
||||
}
|
||||
])
|
||||
|
||||
expect(chatMessageText(message)).toBe('summarize @file:`src/main.ts` for me')
|
||||
})
|
||||
|
||||
it('projects durable timeline kinds without inspecting their text', () => {
|
||||
const messages = toChatMessages([
|
||||
{ role: 'user', content: 'real user turn', timestamp: 1 },
|
||||
|
|
|
|||
|
|
@ -311,7 +311,12 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown
|
|||
const attachedContext = textContent.slice(marker.index + marker[0].length)
|
||||
const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))]
|
||||
|
||||
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
|
||||
// The prose keeps the `@file:` token the user typed, so it already chips in
|
||||
// place. Only hoist a ref the prose is missing — a turn persisted by an older
|
||||
// backend that stripped the tokens. Re-listing an inline ref would chip twice.
|
||||
const missing = refs.filter(ref => !visibleText.includes(ref))
|
||||
|
||||
return [missing.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
|
||||
}
|
||||
|
||||
function transcriptContent(displayKind: SessionMessage['display_kind'], content: string): string | null {
|
||||
|
|
|
|||
|
|
@ -112,8 +112,9 @@ def test_expand_file_range_and_folder_listing(sample_repo: Path):
|
|||
)
|
||||
|
||||
assert result.expanded
|
||||
assert "Review and" in result.message
|
||||
assert "Review @file:src/main.py:1-2" not in result.message
|
||||
# The typed `@` tokens stay in the prose — clients render each one as an
|
||||
# inline chip where the user put it, rather than a detached list.
|
||||
assert result.message.startswith("Review @file:src/main.py:1-2 and @folder:src/")
|
||||
assert "--- Attached Context ---" in result.message
|
||||
assert "def alpha():" in result.message
|
||||
assert "return 'changed'" in result.message
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue