mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Selected rows in the model/session/skills pickers and approval/clarify prompts only changed from dim gray to cornsilk, which reads as low contrast on lighter themes and LCDs (reported during TUI v2 blitz). Switch the selected row to `inverse bold` with the brand accent color across modelPicker, sessionPicker, skillsHub, and prompts so the highlight is terminal-portable and unambiguous. Unselected rows stay dim. Also extends the sessionPicker middle meta column (which was always dim) to inherit the row's selection state.
349 lines
9 KiB
TypeScript
349 lines
9 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
|
|
}
|
|
}
|