diff --git a/ui-tui/src/__tests__/terminalParity.test.ts b/ui-tui/src/__tests__/terminalParity.test.ts index 7b822dfc4..224199389 100644 --- a/ui-tui/src/__tests__/terminalParity.test.ts +++ b/ui-tui/src/__tests__/terminalParity.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { terminalParityHints } from '../lib/terminalParity.js' @@ -15,7 +15,30 @@ describe('terminalParityHints', () => { }) it('suggests IDE setup only for VS Code-family terminals that still need bindings', async () => { - const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv) + const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + + const hints = await terminalParityHints( + { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, + { fileOps: { readFile }, homeDir: '/tmp/fake-home' } + ) expect(hints.some(h => h.key === 'ide-setup')).toBe(true) }) + + it('suppresses IDE setup hint when keybindings are already configured', async () => { + const readFile = vi.fn().mockResolvedValue( + JSON.stringify([ + { key: 'shift+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, + { key: 'ctrl+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, + { key: 'cmd+enter', command: 'workbench.action.terminal.sendSequence', when: 'terminalFocus', args: { text: '\\\r\n' } }, + { 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' } } + ]) + ) + + const hints = await terminalParityHints( + { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, + { fileOps: { readFile }, homeDir: '/tmp/fake-home' } + ) + expect(hints.some(h => h.key === 'ide-setup')).toBe(false) + }) }) diff --git a/ui-tui/src/__tests__/terminalSetup.test.ts b/ui-tui/src/__tests__/terminalSetup.test.ts index 6ded9177f..fd4f03d74 100644 --- a/ui-tui/src/__tests__/terminalSetup.test.ts +++ b/ui-tui/src/__tests__/terminalSetup.test.ts @@ -30,6 +30,21 @@ describe('terminalSetup helpers', () => { it('strips line comments from keybindings JSON', () => { expect(stripJsonComments('// comment\n[{"key":"shift+enter"}]')).toBe('\n[{"key":"shift+enter"}]') }) + + it('strips inline comments and block comments', () => { + expect(stripJsonComments('[{"key":"a"} // inline\n]')).toBe('[{"key":"a"} \n]') + expect(stripJsonComments('[/* block */{"key":"a"}]')).toBe('[{"key":"a"}]') + }) + + it('removes trailing commas before ] or }', () => { + expect(JSON.parse(stripJsonComments('[{"key":"a"},]'))).toEqual([{ key: 'a' }]) + expect(JSON.parse(stripJsonComments('[{"key":"a",}]'))).toEqual([{ key: 'a' }]) + }) + + it('preserves comment-like sequences inside strings', () => { + const input = '[{"key":"a","args":{"text":"// not a comment"}}]' + expect(JSON.parse(stripJsonComments(input))).toEqual([{ key: 'a', args: { text: '// not a comment' } }]) + }) }) describe('configureTerminalKeybindings', () => { @@ -48,6 +63,7 @@ describe('configureTerminalKeybindings', () => { expect(result.success).toBe(true) expect(result.requiresRestart).toBe(true) expect(writeFile).toHaveBeenCalledTimes(1) + expect(copyFile).not.toHaveBeenCalled() // no existing file to back up const written = writeFile.mock.calls[0]?.[1] as string expect(written).toContain('shift+enter') expect(written).toContain('cmd+enter') @@ -78,6 +94,24 @@ describe('configureTerminalKeybindings', () => { expect(result.success).toBe(false) expect(result.message).toContain('cmd+z') expect(writeFile).not.toHaveBeenCalled() + expect(copyFile).not.toHaveBeenCalled() // no backup when not writing + }) + + it('backs up existing keybindings.json only when writing changes', async () => { + const mkdir = vi.fn().mockResolvedValue(undefined) + const readFile = vi.fn().mockResolvedValue(JSON.stringify([])) + const writeFile = vi.fn().mockResolvedValue(undefined) + const copyFile = vi.fn().mockResolvedValue(undefined) + + const result = await configureTerminalKeybindings('vscode', { + fileOps: { copyFile, mkdir, readFile, writeFile }, + homeDir: '/Users/me', + platform: 'darwin' + }) + + expect(result.success).toBe(true) + expect(writeFile).toHaveBeenCalledTimes(1) + expect(copyFile).toHaveBeenCalledTimes(1) // backup created before writing }) it('auto-detects the current IDE terminal', async () => { diff --git a/ui-tui/src/__tests__/useComposerState.test.ts b/ui-tui/src/__tests__/useComposerState.test.ts index 0efb7973a..eac8024e0 100644 --- a/ui-tui/src/__tests__/useComposerState.test.ts +++ b/ui-tui/src/__tests__/useComposerState.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { looksLikeDroppedPath } from '../app/useComposerState.js' @@ -12,4 +12,38 @@ describe('looksLikeDroppedPath', () => { expect(looksLikeDroppedPath('hello world')).toBe(false) expect(looksLikeDroppedPath('line one\nline two')).toBe(false) }) + + it('recognizes common image file extensions', () => { + expect(looksLikeDroppedPath('/Users/me/Desktop/photo.jpg')).toBe(true) + expect(looksLikeDroppedPath('/Users/me/Desktop/diagram.png')).toBe(true) + expect(looksLikeDroppedPath('/tmp/capture.webp')).toBe(true) + expect(looksLikeDroppedPath('/tmp/image.gif')).toBe(true) + }) + + it('recognizes file:// URIs with various extensions', () => { + expect(looksLikeDroppedPath('file:///home/user/doc.pdf')).toBe(true) + expect(looksLikeDroppedPath('file:///tmp/screenshot.png')).toBe(true) + }) + + it('recognizes paths with spaces (not backslash-escaped)', () => { + expect(looksLikeDroppedPath('/var/folders/x/T/TemporaryItems/Screenshot 2026-04-21 at 1.04.43 PM.png')).toBe(true) + }) + + it('rejects empty/whitespace-only input', () => { + expect(looksLikeDroppedPath('')).toBe(false) + expect(looksLikeDroppedPath(' ')).toBe(false) + expect(looksLikeDroppedPath('\n')).toBe(false) + }) + + it('rejects URLs that are not file:// URIs', () => { + expect(looksLikeDroppedPath('https://example.com/image.png')).toBe(false) + expect(looksLikeDroppedPath('http://localhost/file.pdf')).toBe(false) + }) + + it('treats leading-slash strings as potential paths (server-side validates)', () => { + // The heuristic is intentionally broad — starts with / could be a path. + // Server-side image.attach / input.detect_drop does real validation. + expect(looksLikeDroppedPath('/help')).toBe(true) + expect(looksLikeDroppedPath('/model sonnet')).toBe(true) + }) }) diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 38c4ec7c3..7a6aa809f 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -15,7 +15,7 @@ import { useQueue } from '../hooks/useQueue.js' import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js' import { readOsc52Clipboard } from '../lib/osc52.js' import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' -import type { InputDetectDropResponse } from '../gatewayTypes.js' +import type { ImageAttachResponse, InputDetectDropResponse } from '../gatewayTypes.js' import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' import { $isBlocked } from './overlayStore.js' @@ -102,7 +102,7 @@ export function useComposerState({ gw, onClipboardPaste, onImageAttached, submit const sid = getUiState().sid if (sid && looksLikeDroppedPath(cleanedText)) { try { - const attached = await gw.request('image.attach', { + const attached = await gw.request('image.attach', { path: cleanedText, session_id: sid }) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 906c98524..f5459c52f 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -573,7 +573,7 @@ export function TextInput({ } if (k.return) { - k.shift || (isMac && isActionMod(k)) + k.shift || (isMac ? isActionMod(k) : k.meta) ? commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1) : cbSubmit.current?.(vRef.current) diff --git a/ui-tui/src/lib/terminalParity.ts b/ui-tui/src/lib/terminalParity.ts index ab62a1884..bed2ee7d5 100644 --- a/ui-tui/src/lib/terminalParity.ts +++ b/ui-tui/src/lib/terminalParity.ts @@ -1,4 +1,4 @@ -import { detectVSCodeLikeTerminal, shouldPromptForTerminalSetup } from './terminalSetup.js' +import { detectVSCodeLikeTerminal, shouldPromptForTerminalSetup, type FileOps } from './terminalSetup.js' export type MacTerminalHint = { key: string @@ -24,11 +24,14 @@ export function detectMacTerminalContext(env: NodeJS.ProcessEnv = process.env): } } -export async function terminalParityHints(env: NodeJS.ProcessEnv = process.env): Promise { +export async function terminalParityHints( + env: NodeJS.ProcessEnv = process.env, + options?: { fileOps?: Partial; homeDir?: string } +): Promise { const ctx = detectMacTerminalContext(env) const hints: MacTerminalHint[] = [] - if (ctx.vscodeLike && (await shouldPromptForTerminalSetup({ env }))) { + if (ctx.vscodeLike && (await shouldPromptForTerminalSetup({ env, fileOps: options?.fileOps, homeDir: options?.homeDir }))) { hints.push({ key: 'ide-setup', tone: 'info', diff --git a/ui-tui/src/lib/terminalSetup.ts b/ui-tui/src/lib/terminalSetup.ts index 0a4d43b10..54274e515 100644 --- a/ui-tui/src/lib/terminalSetup.ts +++ b/ui-tui/src/lib/terminalSetup.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' export type SupportedTerminal = 'cursor' | 'vscode' | 'windsurf' -type FileOps = { +export type FileOps = { copyFile: typeof copyFile mkdir: typeof mkdir readFile: typeof readFile @@ -83,8 +83,57 @@ export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): 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 { - return content.replace(/^\s*\/\/.*$/gm, '') + 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') } function isRemoteShellSession(env: NodeJS.ProcessEnv): boolean { @@ -127,7 +176,6 @@ export async function configureTerminalKeybindings( env?: NodeJS.ProcessEnv fileOps?: Partial homeDir?: string - now?: () => Date platform?: NodeJS.Platform } ): Promise { @@ -159,9 +207,10 @@ export async function configureTerminalKeybindings( await ops.mkdir(configDir, { recursive: true }) let keybindings: unknown[] = [] + let hasExistingFile = false try { const content = await ops.readFile(keybindingsFile, 'utf8') - await backupFile(keybindingsFile, ops) + hasExistingFile = true const parsed: unknown = JSON.parse(stripJsonComments(content)) if (!Array.isArray(parsed)) { return { @@ -208,6 +257,10 @@ export async function configureTerminalKeybindings( } } + if (hasExistingFile) { + await backupFile(keybindingsFile, ops) + } + await ops.writeFile(keybindingsFile, `${JSON.stringify(keybindings, null, 2)}\n`, 'utf8') return {