Merge remote-tracking branch 'origin/main' into bb/tui-long-session-perf
# Conflicts: # ui-tui/src/app/interfaces.ts
This commit is contained in:
@@ -252,7 +252,6 @@ Primary event types the client handles today:
|
||||
| `sudo.request` | `{ request_id }` |
|
||||
| `secret.request` | `{ prompt, env_var, request_id }` |
|
||||
| `background.complete` | `{ task_id, text }` |
|
||||
| `btw.complete` | `{ text }` |
|
||||
| `error` | `{ message }` |
|
||||
| `gateway.stderr` | synthesized from child stderr |
|
||||
| `gateway.protocol_error` | synthesized from malformed stdout |
|
||||
|
||||
@@ -9,9 +9,9 @@ import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.j
|
||||
* Returns no-op functions when fullscreen mode is disabled.
|
||||
*/
|
||||
export function useSelection(): {
|
||||
copySelection: () => string
|
||||
copySelection: () => Promise<string>
|
||||
/** Copy without clearing the highlight (for copy-on-select). */
|
||||
copySelectionNoClear: () => string
|
||||
copySelectionNoClear: () => Promise<string>
|
||||
clearSelection: () => void
|
||||
hasSelection: () => boolean
|
||||
/** Read the raw mutable selection state (for drag-to-scroll). */
|
||||
@@ -48,8 +48,8 @@ export function useSelection(): {
|
||||
return useMemo(() => {
|
||||
if (!ink) {
|
||||
return {
|
||||
copySelection: () => '',
|
||||
copySelectionNoClear: () => '',
|
||||
copySelection: async () => '',
|
||||
copySelectionNoClear: async () => '',
|
||||
clearSelection: () => {},
|
||||
hasSelection: () => false,
|
||||
getState: () => null,
|
||||
|
||||
@@ -1302,11 +1302,13 @@ export default class Ink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current selection to the clipboard without clearing the
|
||||
* highlight. Matches iTerm2's copy-on-select behavior where the selected
|
||||
* region stays visible after the automatic copy.
|
||||
* Copy the current text selection to the system clipboard without clearing the
|
||||
* selection. Returns the copied text when a clipboard path succeeded (native
|
||||
* tool fired, tmux buffer loaded, or OSC 52 emitted), or '' when no path was
|
||||
* taken (e.g. headless Linux without tmux). Matches iTerm2's copy-on-select
|
||||
* behavior where the selected region stays visible after the automatic copy.
|
||||
*/
|
||||
copySelectionNoClear(): string {
|
||||
async copySelectionNoClear(): Promise<string> {
|
||||
if (!hasSelection(this.selection)) {
|
||||
return ''
|
||||
}
|
||||
@@ -1314,28 +1316,41 @@ export default class Ink {
|
||||
const text = getSelectedText(this.selection, this.frontFrame.screen)
|
||||
|
||||
if (text) {
|
||||
// Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux
|
||||
// drops it silently unless allow-passthrough is on — no regression).
|
||||
void setClipboard(text).then(raw => {
|
||||
if (raw) {
|
||||
this.options.stdout.write(raw)
|
||||
try {
|
||||
const { sequence, success } = await setClipboard(text)
|
||||
|
||||
if (sequence) {
|
||||
this.options.stdout.write(sequence)
|
||||
}
|
||||
})
|
||||
|
||||
if (success) {
|
||||
return text
|
||||
}
|
||||
|
||||
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
|
||||
console.error('[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence')
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
|
||||
console.error('[clipboard] 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()
|
||||
|
||||
|
||||
@@ -26,4 +26,26 @@ describe('shouldEmitClipboardSequence', () => {
|
||||
shouldEmitClipboardSequence({ HERMES_TUI_COPY_OSC52: '0', TERM: 'xterm-256color' } as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('HERMES_TUI_FORCE_OSC52 takes precedence over TMUX suppression', () => {
|
||||
// Without the override, local-in-tmux suppresses the OSC 52 sequence
|
||||
// so the terminal multiplexer path wins. FORCE_OSC52=1 flips that
|
||||
// back on for users whose tmux config supports passthrough.
|
||||
expect(shouldEmitClipboardSequence({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
expect(
|
||||
shouldEmitClipboardSequence({
|
||||
HERMES_TUI_FORCE_OSC52: '1',
|
||||
TMUX: '/tmp/t,1,0'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('HERMES_TUI_FORCE_OSC52=0 suppresses OSC 52 even for remote or plain terminals', () => {
|
||||
expect(
|
||||
shouldEmitClipboardSequence({
|
||||
HERMES_TUI_FORCE_OSC52: '0',
|
||||
SSH_CONNECTION: '1'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -162,10 +166,23 @@ export async function tmuxLoadBuffer(text: string): Promise<boolean> {
|
||||
* utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over
|
||||
* SSH these would write to the remote clipboard — OSC 52 is the right path there.
|
||||
*
|
||||
* Returns the sequence for the caller to write to stdout (raw OSC 52
|
||||
* outside tmux, DCS-wrapped inside).
|
||||
* Returns { sequence, success }:
|
||||
* - `sequence` is the bytes to write to stdout (raw OSC 52 outside tmux,
|
||||
* DCS-wrapped inside; empty string when we shouldn't emit).
|
||||
* - `success` is true when we believe SOME path reached the clipboard:
|
||||
* native tool fired (local), tmux buffer loaded, or an OSC 52 sequence
|
||||
* was emitted to the terminal. False only when no path was taken at
|
||||
* all (headless Linux with no tmux + osc52 suppressed, effectively).
|
||||
* This is best-effort — pbcopy/xclip are fire-and-forget, and OSC 52
|
||||
* depends on the outer terminal honoring the sequence — but it lets
|
||||
* callers distinguish "nothing attempted" from "attempted".
|
||||
*/
|
||||
export async function setClipboard(text: string): Promise<string> {
|
||||
export type ClipboardResult = {
|
||||
sequence: string
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export async function setClipboard(text: string): Promise<ClipboardResult> {
|
||||
const b64 = Buffer.from(text, 'utf8').toString('base64')
|
||||
const raw = osc(OSC.CLIPBOARD, 'c', b64)
|
||||
const emitSequence = shouldEmitClipboardSequence(process.env)
|
||||
@@ -177,20 +194,28 @@ export async function setClipboard(text: string): Promise<string> {
|
||||
// (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829).
|
||||
// Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY
|
||||
// forever but SSH_CONNECTION is in tmux's default update-environment and
|
||||
// clears on local attach. Fire-and-forget.
|
||||
if (!process.env['SSH_CONNECTION']) {
|
||||
copyNative(text)
|
||||
}
|
||||
// clears on local attach. Fire-and-forget, but `copyNativeAttempted`
|
||||
// tells us whether ANY native path will be tried on this platform.
|
||||
const nativeAttempted =
|
||||
!process.env['SSH_CONNECTION'] && copyNative(text)
|
||||
|
||||
const tmuxBufferLoaded = await tmuxLoadBuffer(text)
|
||||
|
||||
// Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling
|
||||
// too, and BEL works everywhere for OSC 52.
|
||||
if (tmuxBufferLoaded) {
|
||||
return emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : ''
|
||||
}
|
||||
const sequence = tmuxBufferLoaded
|
||||
? (emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '')
|
||||
: (emitSequence ? raw : '')
|
||||
|
||||
return emitSequence ? raw : ''
|
||||
// Success if any path was taken. Native and tmux are fire-and-forget,
|
||||
// so we can't truly confirm the clipboard was written — but if native
|
||||
// was attempted OR tmux buffer loaded OR we emitted OSC 52, the user's
|
||||
// paste is likely to work. The only false case is "we did literally
|
||||
// nothing" (e.g. local-in-tmux with osc52 suppressed and tmux buffer
|
||||
// load failed), in which case reporting failure to the user is honest.
|
||||
const success = nativeAttempted || tmuxBufferLoaded || sequence.length > 0
|
||||
|
||||
return { sequence, success }
|
||||
}
|
||||
|
||||
// Linux clipboard tool: undefined = not yet probed, null = none available.
|
||||
@@ -198,65 +223,95 @@ export async function setClipboard(text: string): Promise<string> {
|
||||
// Cached after first attempt so repeated mouse-ups skip the probe chain.
|
||||
let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined
|
||||
|
||||
/** Internal: probe once and cache — wl-copy first, then xclip, then xsel. */
|
||||
async function probeLinuxCopy(): Promise<'wl-copy' | 'xclip' | 'xsel' | null> {
|
||||
const opts = { useCwd: false, timeout: 500 }
|
||||
|
||||
const r = await execFileNoThrow('wl-copy', [], opts)
|
||||
|
||||
if (r.code === 0) {
|
||||
return 'wl-copy'
|
||||
}
|
||||
|
||||
const r2 = await execFileNoThrow('xclip', ['-selection', 'clipboard'], opts)
|
||||
|
||||
if (r2.code === 0) {
|
||||
return 'xclip'
|
||||
}
|
||||
|
||||
const r3 = await execFileNoThrow('xsel', ['--clipboard', '--input'], opts)
|
||||
|
||||
return r3.code === 0 ? 'xsel' : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell out to a native clipboard utility as a safety net for OSC 52.
|
||||
* Only called when not in an SSH session (over SSH, these would write to
|
||||
* the remote machine's clipboard — OSC 52 is the right path there).
|
||||
* Fire-and-forget: failures are silent since OSC 52 may have succeeded.
|
||||
*
|
||||
* Returns true when a native copy path was (or will be) attempted — i.e.
|
||||
* we'll spawn pbcopy on macOS, clip on Windows, or a known-working Linux
|
||||
* tool. Returns false only when we know no native tool is viable (Linux
|
||||
* without DISPLAY/WAYLAND_DISPLAY, or previously-probed-to-null). The
|
||||
* return value is used to decide whether to tell the user the copy
|
||||
* succeeded — spawning is best-effort but good enough to claim success.
|
||||
*
|
||||
* Linux behaviour: if DISPLAY and WAYLAND_DISPLAY are both unset, native
|
||||
* clipboard tools cannot work (they need a display server). In that case
|
||||
* we skip probing entirely and treat linuxCopy as permanently null.
|
||||
*/
|
||||
function copyNative(text: string): void {
|
||||
function copyNative(text: string): boolean {
|
||||
const opts = { input: text, useCwd: false, timeout: 2000 }
|
||||
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
void execFileNoThrow('pbcopy', [], opts)
|
||||
|
||||
return
|
||||
return true
|
||||
case 'linux': {
|
||||
if (linuxCopy === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (linuxCopy === 'wl-copy') {
|
||||
void execFileNoThrow('wl-copy', [], opts)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (linuxCopy === 'xclip') {
|
||||
void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (linuxCopy === 'xsel') {
|
||||
void execFileNoThrow('xsel', ['--clipboard', '--input'], opts)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner.
|
||||
void execFileNoThrow('wl-copy', [], opts).then(r => {
|
||||
if (r.code === 0) {
|
||||
linuxCopy = 'wl-copy'
|
||||
|
||||
return
|
||||
// If we already probed (success or hard-fail), short-circuit.
|
||||
if (linuxCopy !== undefined) {
|
||||
if (linuxCopy === null) {
|
||||
// No working native tool — skip silently.
|
||||
return false
|
||||
}
|
||||
|
||||
void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(r2 => {
|
||||
if (r2.code === 0) {
|
||||
linuxCopy = 'xclip'
|
||||
// linuxCopy is a known-working tool; fire-and-forget.
|
||||
void execFileNoThrow(linuxCopy, linuxCopy === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts)
|
||||
|
||||
return
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(r3 => {
|
||||
linuxCopy = r3.code === 0 ? 'xsel' : null
|
||||
})
|
||||
})
|
||||
})
|
||||
// No display server → native tools will fail immediately. Cache null.
|
||||
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
||||
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
|
||||
console.error('[clipboard] [native] Linux: no DISPLAY or WAYLAND_DISPLAY — native clipboard unavailable')
|
||||
}
|
||||
|
||||
return
|
||||
linuxCopy = null
|
||||
|
||||
return false
|
||||
}
|
||||
// First call: probe in the background and cache the result for future copies.
|
||||
// We don't await — this is fire-and-forget. Treat as an attempt:
|
||||
// the probe will discover a tool and spawn it. If probing finds
|
||||
// nothing, the NEXT copy will short-circuit above.
|
||||
void (async () => {
|
||||
const winner = await probeLinuxCopy()
|
||||
linuxCopy = winner
|
||||
|
||||
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
|
||||
console.error(`[clipboard] [native] Linux: clipboard probe complete → ${winner ?? 'no tool available'}`)
|
||||
}
|
||||
|
||||
// Actually perform the copy with the discovered tool.
|
||||
if (winner) {
|
||||
void execFileNoThrow(winner, winner === 'wl-copy' ? [] : ['-selection', 'clipboard'], opts)
|
||||
}
|
||||
})()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case 'win32':
|
||||
@@ -264,8 +319,10 @@ function copyNative(text: string): void {
|
||||
// imperfect (system locale encoding) but good enough for a fallback.
|
||||
void execFileNoThrow('clip', [], opts)
|
||||
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/** @internal test-only */
|
||||
|
||||
@@ -392,7 +392,7 @@ const buildComposer = () => ({
|
||||
hasSelection: false,
|
||||
paste: vi.fn(),
|
||||
queueRef: { current: [] as string[] },
|
||||
selection: { copySelection: vi.fn(() => '') },
|
||||
selection: { copySelection: vi.fn(async () => '') },
|
||||
setInput: vi.fn()
|
||||
})
|
||||
|
||||
|
||||
@@ -429,12 +429,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
|
||||
return
|
||||
|
||||
case 'btw.complete':
|
||||
dropBgTask('btw:x')
|
||||
sys(`[btw] ${ev.payload.text}`)
|
||||
|
||||
return
|
||||
|
||||
case 'subagent.spawn_requested':
|
||||
// Child built but not yet running (waiting on ThreadPoolExecutor slot).
|
||||
// Preserve completed state if a later event races in before this one.
|
||||
|
||||
@@ -33,7 +33,7 @@ export type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||
export interface SelectionApi {
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
clearSelection: () => void
|
||||
copySelection: () => string
|
||||
copySelection: () => Promise<string>
|
||||
getState: () => unknown
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
|
||||
@@ -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) {
|
||||
return sys(`copied ${text.length} characters`)
|
||||
} else {
|
||||
return sys('clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence; HERMES_TUI_DEBUG_CLIPBOARD=1 for details')
|
||||
}
|
||||
}
|
||||
|
||||
if (arg && Number.isNaN(parseInt(arg, 10))) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
|
||||
import type {
|
||||
BackgroundStartResponse,
|
||||
BtwStartResponse,
|
||||
ConfigGetValueResponse,
|
||||
ConfigSetResponse,
|
||||
ImageAttachResponse,
|
||||
@@ -26,7 +25,7 @@ const persistedModelArg = (arg: string) => {
|
||||
|
||||
export const sessionCommands: SlashCommand[] = [
|
||||
{
|
||||
aliases: ['bg'],
|
||||
aliases: ['bg', 'btw'],
|
||||
help: 'launch a background prompt',
|
||||
name: 'background',
|
||||
run: (arg, ctx) => {
|
||||
@@ -47,23 +46,6 @@ export const sessionCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'by-the-way follow-up',
|
||||
name: 'btw',
|
||||
run: (arg, ctx) => {
|
||||
if (!arg) {
|
||||
return ctx.transcript.sys('/btw <question>')
|
||||
}
|
||||
|
||||
ctx.gateway.rpc<BtwStartResponse>('prompt.btw', { session_id: ctx.sid, text: arg }).then(
|
||||
ctx.guarded(() => {
|
||||
patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') }))
|
||||
ctx.transcript.sys('btw running…')
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'change or show model',
|
||||
aliases: ['provider'],
|
||||
|
||||
@@ -178,10 +178,6 @@ export interface BackgroundStartResponse {
|
||||
task_id?: string
|
||||
}
|
||||
|
||||
export interface BtwStartResponse {
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
export interface ClarifyRespondResponse {
|
||||
ok?: boolean
|
||||
}
|
||||
@@ -403,7 +399,6 @@ export type GatewayEvent =
|
||||
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
|
||||
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
|
||||
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
|
||||
| { payload: { text: string }; session_id?: string; type: 'btw.complete' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }
|
||||
|
||||
4
ui-tui/src/types/hermes-ink.d.ts
vendored
4
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -84,8 +84,8 @@ declare module '@hermes/ink' {
|
||||
export function withInkSuspended(run: RunExternalProcess): Promise<void>
|
||||
export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void
|
||||
export function useSelection(): {
|
||||
readonly copySelection: () => string
|
||||
readonly copySelectionNoClear: () => string
|
||||
readonly copySelection: () => Promise<string>
|
||||
readonly copySelectionNoClear: () => Promise<string>
|
||||
readonly clearSelection: () => void
|
||||
readonly hasSelection: () => boolean
|
||||
readonly getState: () => unknown
|
||||
|
||||
Reference in New Issue
Block a user