Skip to content

Instantly share code, notes, and snippets.

@pwwang
Created September 11, 2026 18:55
Show Gist options
  • Select an option

  • Save pwwang/b6466ffad2b0f9510cc4e53eed0baad7 to your computer and use it in GitHub Desktop.

Select an option

Save pwwang/b6466ffad2b0f9510cc4e53eed0baad7 to your computer and use it in GitHub Desktop.
quote-comment desktop plugin - slimmed reference example (hermes-example-plugins proposal)
/**
* quote-comment (reference) — right-click a text selection inside a message:
* Copy it, or queue a quote+comment the composer middleware appends to the
* next outgoing message (Submit queues; the middleware injects on send).
*
* Reference surfaces, rationale at each code site: window-capture
* contextmenu + selection/data-role scope on target AND anchorNode (the app
* stops propagation at window capture; same-target listeners still run);
* 'data-hermes-context-menu-trigger' menu-flash stamps (cleared in the
* contextmenu handler + 600ms timer, never on mouseup); position:fixed
* overlays from a STATUSBAR_AREAS.right contribution (always-mounted portal);
* pointerdown row activation + compat-mousedown consumption; globalThis
* listener swap + onDispose cleanup; composer middleware: { ...draft, text }
* rewrite / null cancel / throw pass-through.
*/
import { COMPOSER_AREAS, STATUSBAR_AREAS, atom, useValue, Button, Textarea } from '@hermes/plugin-sdk'
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
import { useState } from 'react'
const menuAtom = atom({ open: false, x: 0, y: 0, text: '' }) // open menu: position + selected text
const dialogAtom = atom({ open: false, quote: '' }) // comment dialog
const pendingAtom = atom([]) // queued {quote, comment} — drained by the middleware on send
let ctxRef = null // ctx is only reachable inside register(); stashed for os.writeClipboard
// Consume the first mousedown after a row activation (the menu unmounts on
// pointerdown; the compat mousedown would closeAll the dialog just opened).
let suppressDismiss = false
let suppressResetTimer = 0
function armDismissSuppression() {
suppressDismiss = true
clearTimeout(suppressResetTimer)
suppressResetTimer = setTimeout(() => { suppressDismiss = false }, 1500)
}
/** '> ' per PARAGRAPH (blank-line-separated block); comment on its own paragraph. */
function formatCommentBlock(quote, comment) {
const quoted = quote.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean).map(p => `> ${p}`).join('\n\n')
return comment ? `${quoted}\n\n${comment}` : quoted
}
// Roots stamped with the app's own context-menu opt-out attribute during a
// right-click gesture so its window-capture handler skips them — no flash,
// no double menu. Cleared in the contextmenu handler or by the 600ms safety
// timer; NOT on mouseup (Windows fires contextmenu after mouseup).
const markedRoots = new Set()
let markClearTimer = 0
function markRoot(root) {
if (root && !markedRoots.has(root)) {
root.setAttribute('data-hermes-context-menu-trigger', '')
markedRoots.add(root)
}
clearTimeout(markClearTimer)
markClearTimer = setTimeout(clearMarks, 600)
}
function clearMarks() {
clearTimeout(markClearTimer)
for (const root of markedRoots) root.removeAttribute('data-hermes-context-menu-trigger')
markedRoots.clear()
}
function selectionContext() {
const selection = window.getSelection()
const text = selection ? selection.toString().trim() : ''
return text ? { selection, text } : null
}
/** Nearest message root (the data-role scope) of a node. */
function messageRootOf(node) {
const el = node instanceof Element ? node : node?.parentElement
return el?.closest?.('[data-role="user"], [data-role="assistant"]') ?? null
}
/** Theme vars only — never hardcoded colors. */
const SURFACE = { position: 'fixed', zIndex: 'var(--z-over-modal)', background: 'var(--ui-bg-elevated)', border: '1px solid var(--ui-stroke-secondary)', borderRadius: 6, color: 'var(--ui-text-primary)', fontSize: 13, fontFamily: 'inherit' }
// Statusbar contribution = always-mounted portal for the fixed overlays.
function PluginRoot() {
const menu = useValue(menuAtom)
const dialog = useValue(dialogAtom)
return jsxs(Fragment, {
children: [
menu.open && jsx(ContextMenuCard, { key: 'menu', x: menu.x, y: menu.y, text: menu.text }),
dialog.open && jsx(CommentDialog, { key: 'dialog', quote: dialog.quote })
]
})
}
const closeMenu = () => menuAtom.set({ open: false, x: 0, y: 0, text: '' })
const closeDialog = () => dialogAtom.set({ open: false, quote: '' })
const closeAll = () => { closeMenu(); closeDialog() }
/** Rows activate on POINTERDOWN: the app's Radix DismissableLayer
* preventDefaults outside pointerdowns, killing the subsequent click. */
function MenuRow({ label, onSelect }) {
const [hover, setHover] = useState(false)
return jsx('button', {
type: 'button', onPointerDown: onSelect,
onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false),
style: { display: 'block', width: '100%', padding: '5px 10px', border: 0, borderRadius: 4, textAlign: 'left', color: 'var(--ui-text-primary)', fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', background: hover ? 'var(--ui-control-hover-background)' : 'transparent' },
children: label
})
}
function ContextMenuCard({ x, y, text }) {
const left = Math.min(x, window.innerWidth - 176)
const top = Math.min(y, window.innerHeight - 100)
return jsxs('div', {
'data-qc': 'menu',
style: { ...SURFACE, left, top, minWidth: 160, padding: 4 },
children: [
jsx(MenuRow, { label: 'Copy', onSelect: () => { armDismissSuppression(); void ctxRef.os.writeClipboard(text); closeMenu() } }),
jsx(MenuRow, { label: 'Comment', onSelect: () => { armDismissSuppression(); dialogAtom.set({ open: true, quote: text }); closeMenu() } })
]
})
}
function CommentDialog({ quote }) {
const [comment, setComment] = useState('')
const truncated = quote.length > 200 ? `${quote.slice(0, 200)}…` : quote
const submit = () => {
pendingAtom.set([...pendingAtom.get(), { quote, comment: comment.trim() }]) // queue on Submit
window.getSelection()?.removeAllRanges() // so the quote no longer floats over the chat
closeDialog()
}
return jsxs('div', {
'data-qc': 'dialog',
style: { ...SURFACE, left: '50%', top: '45%', transform: 'translate(-50%, -50%)', width: 'min(440px, calc(100vw - 32px))', padding: 14 },
children: [
jsx('div', {
title: quote,
style: { maxHeight: 120, overflow: 'auto', padding: 8, marginBottom: 10, border: '1px solid var(--ui-stroke-secondary)', borderRadius: 4, color: 'var(--ui-text-secondary)', fontSize: 12, lineHeight: 1.5, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' },
children: truncated
}),
jsx(Textarea, {
// Callback-ref focus beats autoFocus, which loses the race against the
// compat mousedown's focus-steal (preventDefaulted in onMouseDown).
ref: el => { el?.focus() },
value: comment, onChange: e => setComment(e.target.value), placeholder: 'Add a comment…',
style: { width: '100%', minHeight: 72, resize: 'vertical', fontSize: 13 },
onKeyDown: e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit() } } // Escape: global keydown
}),
jsxs('div', {
style: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 10 },
children: [
jsx(Button, { type: 'button', variant: 'secondary', onClick: closeDialog, children: 'Cancel' }),
jsx(Button, { type: 'button', variant: 'default', onClick: submit, children: 'Submit' })
]
})
]
})
}
/** Queued blocks drain on the next send; blank line before the block when
* the draft is non-empty. */
function middlewareHandler(draft) {
const pending = pendingAtom.get()
if (pending.length === 0) return draft
const block = pending.map(({ quote, comment }) => formatCommentBlock(quote, comment)).join('\n\n')
pendingAtom.set([])
const base = draft.text ? draft.text.trimEnd() : ''
return { ...draft, text: base ? `${base}\n\n${block}` : block }
}
// Hot reload: a re-evaluated plugin removes the previous incarnation's
// listener before adding its own — no stacking on dead closures.
const KEY_CONTEXTMENU = '__qc_ctxmenu_handler'
const KEY_MOUSEDOWN = '__qc_mousedown_handler'
const KEY_KEYDOWN = '__qc_keydown_handler'
const KEY_SCROLL = '__qc_scroll_handler'
function bindOnce(key, target, type, fn, capture) {
const previous = globalThis[key]
if (previous) target.removeEventListener(type, previous, capture)
target.addEventListener(type, fn, capture)
globalThis[key] = fn
}
function unbind(key, target, type, capture) {
const fn = globalThis[key]
if (fn) { target.removeEventListener(type, fn, capture); delete globalThis[key] }
}
export default {
id: 'quote-comment',
name: 'Quote & Comment',
register(ctx) {
ctxRef = ctx
ctx.register({ id: 'portal', area: STATUSBAR_AREAS.right, order: 130, render: () => jsx(PluginRoot, {}) })
ctx.register({ id: 'middleware', area: COMPOSER_AREAS.middleware, data: { handler: middlewareHandler } })
// WINDOW capture, not document: the app's AppContextMenu listens on window
// capture and stopPropagation()s every right-click, killing document-level
// listeners — stopPropagation does NOT stop same-target ones (that is
// stopImmediatePropagation), so ours runs after the app's, which is what
// makes the trigger-stamp suppression work.
const onContextMenu = event => {
clearMarks() // the app has decided on this gesture — drop the stamps
const info = selectionContext()
// No selection or right-click outside message text: stand down (also
// closes any stale menu), leave the gesture to the app.
if (!info || !messageRootOf(event.target) || !messageRootOf(info.selection.anchorNode)) {
closeAll()
return
}
event.preventDefault() // suppressing the app menu means WE must offer Copy
event.stopPropagation()
menuAtom.set({ open: true, x: event.clientX, y: event.clientY, text: info.text })
}
// Mousedowns inside a surface ('[data-qc]') or on detached nodes are
// ignored — a row unmounts inside its pointerdown handler, so the compat
// mousedown targets a dead node (closest('[data-qc]') is null); reading it
// as "outside" would close the dialog just opened (consumed via
// suppressDismiss first).
const onMouseDown = event => {
if (suppressDismiss) {
suppressDismiss = false
event.preventDefault() // else focus moves to the re-hit-tested node, stealing it from the textarea
return
}
if (event.button === 2) { // right-button press over a message selection:
const info = selectionContext() // stamp the roots BEFORE the contextmenu
if (info && messageRootOf(event.target) && messageRootOf(info.selection.anchorNode)) {
markRoot(messageRootOf(event.target)) // event fires, so the app's
markRoot(messageRootOf(info.selection.anchorNode)) // handler skips them
}
}
const target = event.target instanceof Element ? event.target : null
if (!target || !target.isConnected || target.closest('[data-qc]')) return
closeAll()
}
const onKeyDown = event => {
if (event.key !== 'Escape') return
if (dialogAtom.get().open) closeDialog()
else closeAll()
}
// A scroll outside a surface would leave the fixed menu behind (the
// capture-phase target identifies the scroller; inside-scrolls are fine).
const onScroll = event => {
if (event.target instanceof Element && event.target.closest('[data-qc]')) return
closeAll()
}
bindOnce(KEY_CONTEXTMENU, window, 'contextmenu', onContextMenu, true)
bindOnce(KEY_MOUSEDOWN, window, 'mousedown', onMouseDown, true)
bindOnce(KEY_KEYDOWN, window, 'keydown', onKeyDown, true)
bindOnce(KEY_SCROLL, window, 'scroll', onScroll, true)
// Full unload (disable/remove) gives the app its native menu back.
ctx.onDispose(() => {
clearMarks()
unbind(KEY_CONTEXTMENU, window, 'contextmenu', true)
unbind(KEY_MOUSEDOWN, window, 'mousedown', true)
unbind(KEY_KEYDOWN, window, 'keydown', true)
unbind(KEY_SCROLL, window, 'scroll', true)
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment