fix(tui): restore macOS copy behavior and theme polish (#17131)

This PR groups the TUI fixes that restore macOS Terminal usability and clean up the theme/composer regressions:

- copy transcript selections on macOS drag-release so Terminal.app users can copy while mouse tracking is enabled
- copy composer selections on macOS drag-release; composer selection is internal to TextInput and does not use the global Ink selection bus
- keep IDE Cmd+C forwarding setup macOS-only, and make keybinding conflict checks respect simple when-clause overlap/negation
- force truecolor before chalk initializes (unless NO_COLOR / FORCE_COLOR / HERMES_TUI_TRUECOLOR opt-outs apply) so the default banner keeps its gold/amber/bronze gradient in Terminal.app
- move TUI surfaces onto semantic theme tokens and preserve skin prompt symbols as bare tokens with renderer-owned spacing
- render focused placeholders as dim hint text in TTY mode instead of inverse/selected-looking synthetic cursor text
This commit is contained in:
brooklyn! 2026-04-28 16:47:14 -07:00 committed by GitHub
parent a9efa46b69
commit 6b09df39be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 828 additions and 337 deletions

View file

@ -25,6 +25,7 @@ export type TerminalSetupResult = {
}
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
const COPY_SEQUENCE = '\u001b[99;13u'
const MULTILINE_SEQUENCE = '\\\r\n'
const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = {
@ -33,7 +34,14 @@ const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string
windsurf: { appName: 'Windsurf', label: 'Windsurf' }
}
const TARGET_BINDINGS: Keybinding[] = [
const MAC_COPY_BINDING: Keybinding = {
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && terminalTextSelected',
args: { text: COPY_SEQUENCE }
}
const BASE_BINDINGS: Keybinding[] = [
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
@ -66,6 +74,9 @@ const TARGET_BINDINGS: Keybinding[] = [
}
]
const targetBindings = (platform: NodeJS.Platform): Keybinding[] =>
platform === 'darwin' ? [MAC_COPY_BINDING, ...BASE_BINDINGS] : BASE_BINDINGS
export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal {
const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? ''
@ -172,6 +183,90 @@ 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
}
type WhenRequirements = {
forbidden: Set<string>
required: Set<string>
}
const WHEN_TOKEN_RE = /!?[A-Za-z_][\w.]*/g
function parseWhenRequirements(when: string): WhenRequirements {
const required = new Set<string>()
const forbidden = new Set<string>()
for (const [token] of when.matchAll(WHEN_TOKEN_RE)) {
if (token.startsWith('!')) {
forbidden.add(token.slice(1))
} else {
required.add(token)
}
}
return { forbidden, required }
}
function requirementsContradict(a: WhenRequirements, b: WhenRequirements): boolean {
for (const token of a.required) {
if (b.forbidden.has(token)) {
return true
}
}
for (const token of b.required) {
if (a.forbidden.has(token)) {
return true
}
}
return false
}
function whensOverlap(a: string, b: string): boolean {
if (a === b) {
return true
}
// Empty when = global, overlaps every context.
if (!a || !b) {
return true
}
const left = parseWhenRequirements(a)
const right = parseWhenRequirements(b)
if (requirementsContradict(left, right)) {
return false
}
// This intentionally avoids a full VS Code when-clause parser. If two
// same-key bindings share a positive context token and don't explicitly
// contradict each other, they can fire together in that context.
for (const token of left.required) {
if (right.required.has(token)) {
return true
}
}
return false
}
// VS Code allows multiple bindings on the same key as long as their `when`
// clauses don't overlap. We flag a conflict when the contexts overlap but
// the bindings differ — e.g. existing `terminalFocus` cmd+c overlaps with
// our `terminalFocus && terminalTextSelected`, so the existing binding
// would shadow ours when text isn't selected.
function bindingsConflict(existing: Keybinding, target: Keybinding): boolean {
if (existing.key !== target.key) {
return false
}
if (!whensOverlap(existing.when ?? '', target.when ?? '')) {
return false
}
return !sameBinding(existing, target)
}
async function backupFile(filePath: string, ops: FileOps): Promise<void> {
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
@ -240,10 +335,9 @@ export async function configureTerminalKeybindings(
}
}
const conflicts = TARGET_BINDINGS.filter(target =>
keybindings.some(
existing => isKeybinding(existing) && existing.key === target.key && !sameBinding(existing, target)
)
const targets = targetBindings(platform)
const conflicts = targets.filter(target =>
keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target))
)
if (conflicts.length) {
@ -256,7 +350,7 @@ export async function configureTerminalKeybindings(
let added = 0
for (const target of TARGET_BINDINGS.slice().reverse()) {
for (const target of targets.slice().reverse()) {
const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target))
if (!exists) {
@ -340,7 +434,7 @@ export async function shouldPromptForTerminalSetup(options?: {
return true
}
return TARGET_BINDINGS.some(
return targetBindings(platform).some(
target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))
)
} catch {