mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
fix(clipboard): dashboard Ctrl+C direct copy; TUI honest feedback; HERMES_TUI_FORCE_OSC52
- Dashboard copy: direct Clipboard API on Ctrl+C/Cmd+C (user gesture); send Escape to TUI to clear selection; Ctrl+Shift+C kept as fallback. - TUI /copy: copySelection() async; only reports success if OSC52 emitted. - Add HERMES_TUI_FORCE_OSC52 env var to override native-tool detection. - Fixes "copied N chars" false-positive when clipboard backend absent. Changes: web/src/pages/ChatPage.tsx — direct navigator.clipboard.writeText ui-tui/packages/hermes-ink/src/ink/ink.tsx — async copySelection ui-tui/packages/hermes-ink/src/ink/termio/osc.ts — HERMES_TUI_FORCE_OSC52 ui-tui/src/app/slash/commands/core.ts — async /copy with honest feedback
This commit is contained in:
@@ -1301,7 +1301,13 @@ export default class Ink {
|
||||
* highlight. Matches iTerm2's copy-on-select behavior where the selected
|
||||
* region stays visible after the automatic copy.
|
||||
*/
|
||||
copySelectionNoClear(): string {
|
||||
/**
|
||||
* Copy the current text selection to the system clipboard without clearing the
|
||||
* selection. Returns the copied text on success (empty if no selection or
|
||||
* clipboard operation failed). Success is determined by whether an OSC 52
|
||||
* sequence was emitted (native/tmux paths do not produce a sequence).
|
||||
*/
|
||||
async copySelectionNoClear(): Promise<string> {
|
||||
if (!hasSelection(this.selection)) {
|
||||
return ''
|
||||
}
|
||||
@@ -1309,28 +1315,36 @@ export default class Ink {
|
||||
const text = getSelectedText(this.selection, this.frontFrame.screen)
|
||||
|
||||
if (text) {
|
||||
void setClipboard(text).then(raw => {
|
||||
try {
|
||||
const raw = await setClipboard(text)
|
||||
if (raw) {
|
||||
this.options.stdout.write(raw)
|
||||
} else if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
|
||||
return text
|
||||
}
|
||||
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
|
||||
console.error('[clipboard] [osc52] no sequence emitted — native clipboard or tmux buffer path in use')
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
|
||||
console.error('[clipboard] [osc52] error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current text selection to the system clipboard via OSC 52
|
||||
* and clear the selection. Returns the copied text (empty if no selection).
|
||||
* and clear the selection. Returns the copied text (empty if no selection
|
||||
* or clipboard operation failed).
|
||||
*/
|
||||
copySelection(): string {
|
||||
async copySelection(): Promise<string> {
|
||||
if (!hasSelection(this.selection)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const text = this.copySelectionNoClear()
|
||||
const text = await this.copySelectionNoClear()
|
||||
clearSelection(this.selection)
|
||||
this.notifySelectionChange()
|
||||
|
||||
|
||||
@@ -84,7 +84,11 @@ export function getClipboardPath(): ClipboardPath {
|
||||
}
|
||||
|
||||
export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const override = (env.HERMES_TUI_CLIPBOARD_OSC52 ?? env.HERMES_TUI_COPY_OSC52 ?? '').trim()
|
||||
const override = (
|
||||
env.HERMES_TUI_FORCE_OSC52 ??
|
||||
env.HERMES_TUI_CLIPBOARD_OSC52 ??
|
||||
env.HERMES_TUI_COPY_OSC52 ?? ''
|
||||
).trim()
|
||||
|
||||
if (ENV_ON_RE.test(override)) {
|
||||
return true
|
||||
|
||||
@@ -251,11 +251,17 @@ export const coreCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'copy selection or assistant message',
|
||||
name: 'copy',
|
||||
run: (arg, ctx) => {
|
||||
run: async (arg, ctx) => {
|
||||
const { sys } = ctx.transcript
|
||||
|
||||
if (!arg && ctx.composer.hasSelection && ctx.composer.selection.copySelection()) {
|
||||
return sys('copied selection')
|
||||
if (!arg && ctx.composer.hasSelection) {
|
||||
const text = await ctx.composer.selection.copySelection()
|
||||
if (text) {
|
||||
// Include character count to match user's reported message format
|
||||
return sys(`copied ${text.length} characters`)
|
||||
} else {
|
||||
return sys('clipboard copy failed — no OSC 52 emitted; see HERMES_TUI_DEBUG_CLIPBOARD')
|
||||
}
|
||||
}
|
||||
|
||||
if (arg && Number.isNaN(parseInt(arg, 10))) {
|
||||
|
||||
@@ -290,7 +290,9 @@ export default function ChatPage() {
|
||||
term.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.type !== "keydown") return true;
|
||||
|
||||
const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
|
||||
// Copy: Cmd+C on macOS, Ctrl+C on other platforms (when selection exists)
|
||||
// Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others
|
||||
const copyModifier = isMac ? ev.metaKey : ev.ctrlKey;
|
||||
const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
|
||||
|
||||
if (copyModifier && ev.key.toLowerCase() === "c") {
|
||||
@@ -299,9 +301,12 @@ export default function ChatPage() {
|
||||
navigator.clipboard.writeText(sel).catch((err) => {
|
||||
console.warn("[dashboard clipboard] direct copy failed:", err.message);
|
||||
});
|
||||
// Send Escape to the TUI to clear its selection overlay
|
||||
term.write("\x1b");
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// No selection → let Ctrl+C pass through as interrupt
|
||||
}
|
||||
|
||||
if (pasteModifier && ev.key.toLowerCase() === "v") {
|
||||
|
||||
Reference in New Issue
Block a user