Files
hermes/ui-tui/src/lib/terminalSetup.ts
Brooklyn Nicholson dd5ead1007 fix(tui): preserve prior segment output on Ctrl+C interrupt
interruptTurn only flushed the in-flight streaming chunk (bufRef) to
the transcript before calling idle(), which wiped segmentMessages and
pendingSegmentTools. Every tool call and commentary line the agent had
already emitted in the current turn disappeared the moment the user
cancelled, even though that output is exactly what they want to keep
when they hit Ctrl+C (quote from the blitz feedback: "everything was
fine up until the point where you wanted to push to main").

Append each flushed segment message to the transcript first, then
render the in-flight partial with the `*[interrupted]*` marker and its
pendingSegmentTools. Sys-level "interrupted" note still fires when
there is nothing to preserve.
2026-04-21 14:48:50 -05:00

350 lines
9.0 KiB
TypeScript

import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
export type SupportedTerminal = 'cursor' | 'vscode' | 'windsurf'
export type FileOps = {
copyFile: typeof copyFile
mkdir: typeof mkdir
readFile: typeof readFile
writeFile: typeof writeFile
}
type Keybinding = {
args?: { text?: string }
command?: string
key?: string
when?: string
}
export type TerminalSetupResult = {
message: string
requiresRestart?: boolean
success: boolean
}
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
const MULTILINE_SEQUENCE = '\\\r\n'
const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = {
vscode: { appName: 'Code', label: 'VS Code' },
cursor: { appName: 'Cursor', label: 'Cursor' },
windsurf: { appName: 'Windsurf', label: 'Windsurf' }
}
const TARGET_BINDINGS: Keybinding[] = [
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: MULTILINE_SEQUENCE }
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: MULTILINE_SEQUENCE }
},
{
key: 'cmd+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: MULTILINE_SEQUENCE }
},
{
key: 'cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;9u' }
},
{
key: 'shift+cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;10u' }
}
]
export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal {
const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? ''
if (env['CURSOR_TRACE_ID'] || askpass.includes('cursor')) {
return 'cursor'
}
if (askpass.includes('windsurf')) {
return 'windsurf'
}
if (env['TERM_PROGRAM'] === 'vscode' || env['VSCODE_GIT_IPC_HANDLE']) {
return 'vscode'
}
return null
}
/**
* Strip JSONC features (// line comments, /* block comments *\/, trailing commas)
* so the result is valid JSON parseable by JSON.parse().
* Handles comments inside strings correctly (preserves them).
*/
export function stripJsonComments(content: string): string {
let result = ''
let i = 0
const len = content.length
while (i < len) {
const ch = content[i]!
// String literal — copy as-is, including any comment-like chars inside
if (ch === '"') {
let j = i + 1
while (j < len) {
if (content[j] === '\\') {
j += 2 // skip escaped char
} else if (content[j] === '"') {
j++
break
} else {
j++
}
}
result += content.slice(i, j)
i = j
continue
}
// Line comment
if (ch === '/' && content[i + 1] === '/') {
const eol = content.indexOf('\n', i)
i = eol === -1 ? len : eol
continue
}
// Block comment
if (ch === '/' && content[i + 1] === '*') {
const end = content.indexOf('*/', i + 2)
i = end === -1 ? len : end + 2
continue
}
result += ch
i++
}
// Remove trailing commas before ] or }
return result.replace(/,(\s*[}\]])/g, '$1')
}
export function isRemoteShellSession(env: NodeJS.ProcessEnv): boolean {
return Boolean(env['SSH_CONNECTION'] || env['SSH_TTY'] || env['SSH_CLIENT'])
}
export function getVSCodeStyleConfigDir(
appName: string,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
homeDir: string = homedir()
): null | string {
if (platform === 'darwin') {
return join(homeDir, 'Library', 'Application Support', appName, 'User')
}
if (platform === 'win32') {
return env['APPDATA'] ? join(env['APPDATA'], appName, 'User') : null
}
return join(homeDir, '.config', appName, 'User')
}
function isKeybinding(value: unknown): value is Keybinding {
return typeof value === 'object' && value !== null
}
function sameBinding(a: Keybinding, b: Keybinding): boolean {
return a.key === b.key && a.command === b.command && a.when === b.when && a.args?.text === b.args?.text
}
async function backupFile(filePath: string, ops: FileOps): Promise<void> {
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
}
export async function configureTerminalKeybindings(
terminal: SupportedTerminal,
options?: {
env?: NodeJS.ProcessEnv
fileOps?: Partial<FileOps>
homeDir?: string
platform?: NodeJS.Platform
}
): Promise<TerminalSetupResult> {
const env = options?.env ?? process.env
const platform = options?.platform ?? process.platform
const homeDir = options?.homeDir ?? homedir()
const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) }
const meta = TERMINAL_META[terminal]
if (isRemoteShellSession(env)) {
return {
success: false,
message: `${meta.label} terminal setup must be run on the local machine, not inside an SSH session.`
}
}
const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir)
if (!configDir) {
return {
success: false,
message: `Could not determine ${meta.label} settings path on this platform.`
}
}
const keybindingsFile = join(configDir, 'keybindings.json')
try {
await ops.mkdir(configDir, { recursive: true })
let keybindings: unknown[] = []
let hasExistingFile = false
try {
const content = await ops.readFile(keybindingsFile, 'utf8')
hasExistingFile = true
const parsed: unknown = JSON.parse(stripJsonComments(content))
if (!Array.isArray(parsed)) {
return {
success: false,
message: `${meta.label} keybindings.json is not a JSON array: ${keybindingsFile}`
}
}
keybindings = parsed
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code
if (code !== 'ENOENT') {
return {
success: false,
message: `Failed to read ${meta.label} keybindings: ${error}`
}
}
}
const conflicts = TARGET_BINDINGS.filter(target =>
keybindings.some(
existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target)
)
)
if (conflicts.length) {
return {
success: false,
message:
`Existing terminal keybindings would conflict in ${keybindingsFile}: ` + conflicts.map(c => c.key).join(', ')
}
}
let added = 0
for (const target of TARGET_BINDINGS.slice().reverse()) {
const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target))
if (!exists) {
keybindings.unshift(target)
added += 1
}
}
if (!added) {
return {
success: true,
message: `${meta.label} terminal keybindings already configured.`
}
}
if (hasExistingFile) {
await backupFile(keybindingsFile, ops)
}
await ops.writeFile(keybindingsFile, `${JSON.stringify(keybindings, null, 2)}\n`, 'utf8')
return {
success: true,
requiresRestart: true,
message: `Added ${added} ${meta.label} terminal keybinding${added === 1 ? '' : 's'} in ${keybindingsFile}`
}
} catch (error) {
return {
success: false,
message: `Failed to configure ${meta.label} terminal shortcuts: ${error}`
}
}
}
export async function configureDetectedTerminalKeybindings(options?: {
env?: NodeJS.ProcessEnv
fileOps?: Partial<FileOps>
homeDir?: string
platform?: NodeJS.Platform
}): Promise<TerminalSetupResult> {
const detected = detectVSCodeLikeTerminal(options?.env ?? process.env)
if (!detected) {
return {
success: false,
message: 'No supported IDE terminal detected. Supported: VS Code, Cursor, Windsurf.'
}
}
return configureTerminalKeybindings(detected, options)
}
export async function shouldPromptForTerminalSetup(options?: {
env?: NodeJS.ProcessEnv
fileOps?: Partial<FileOps>
homeDir?: string
platform?: NodeJS.Platform
}): Promise<boolean> {
const env = options?.env ?? process.env
const detected = detectVSCodeLikeTerminal(env)
if (!detected || isRemoteShellSession(env)) {
return false
}
const platform = options?.platform ?? process.platform
const homeDir = options?.homeDir ?? homedir()
const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) }
const meta = TERMINAL_META[detected]
const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir)
if (!configDir) {
return false
}
try {
const content = await ops.readFile(join(configDir, 'keybindings.json'), 'utf8')
const parsed: unknown = JSON.parse(stripJsonComments(content))
if (!Array.isArray(parsed)) {
return true
}
return TARGET_BINDINGS.some(
target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))
)
} catch {
return true
}
}