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

@ -39,6 +39,15 @@ describe('enhanced keyboard modifier parsing', () => {
expect(event.key.super).toBe(true)
})
it('preserves forwarded VS Code/Cursor Cmd+C copy sequence as ctrl+super+c', () => {
const parsed = parseOne('\u001b[99;13u')
const event = new InputEvent(parsed)
expect(parsed.name).toBe('c')
expect(event.key.ctrl).toBe(true)
expect(event.key.super).toBe(true)
})
it('preserves Cmd on word-delete and word-navigation sequences', () => {
const backspace = new InputEvent(parseOne('\u001b[127;9u'))
const left = new InputEvent(parseOne('\u001b[1;9D'))

View file

@ -35,6 +35,8 @@ export function useSelection(): {
* replaces the old SGR-7 inverse so syntax highlighting stays readable
* under selection). Call once on mount + whenever theme changes. */
setSelectionBgColor: (color: string) => void
/** Monotonic counter incremented on every selection mutation. */
version: () => number
} {
// Look up the Ink instance via stdout — same pattern as instances map.
// StdinContext is available (it's always provided), and the Ink instance
@ -58,7 +60,8 @@ export function useSelection(): {
shiftSelection: () => {},
moveFocus: () => {},
captureScrolledRows: () => {},
setSelectionBgColor: () => {}
setSelectionBgColor: () => {},
version: () => 0
}
}
@ -73,7 +76,8 @@ export function useSelection(): {
shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow),
moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move),
captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side),
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color)
setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color),
version: () => ink.getSelectionVersion()
}
}, [ink])
}

View file

@ -63,6 +63,7 @@ import {
hasSelection,
moveFocus,
selectionBounds,
selectionSignature,
type SelectionState,
selectLineAt,
selectWordAt,
@ -213,7 +214,8 @@ export default class Ink {
// Fired alongside the terminal repaint whenever the selection mutates
// so UI (e.g. footer hints) can react to selection appearing/clearing.
private readonly selectionListeners = new Set<() => void>()
private selectionWasActive = false
private selectionVersion = 0
private lastSelectionSignature = ''
// DOM nodes currently under the pointer (mode-1003 motion). Held here
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
// against this set and mutates it in place.
@ -1661,9 +1663,16 @@ export default class Ink {
return hasSelection(this.selection)
}
getSelectionVersion(): number {
return this.selectionVersion
}
/**
* Subscribe to selection state changes. Fires whenever the selection
* is started, updated, cleared, or copied. Returns an unsubscribe fn.
* mutates anchor/focus moves, drag updates, programmatic clears.
* Does NOT fire on `copySelectionNoClear()` (no mutation, no notify),
* which is why version-based subscribers don't risk re-entrant copies.
* Returns an unsubscribe fn.
*/
subscribeToSelectionChange(cb: () => void): () => void {
this.selectionListeners.add(cb)
@ -1673,14 +1682,18 @@ export default class Ink {
private notifySelectionChange(): void {
this.scheduleRender()
const active = hasSelection(this.selection)
// Only bump version when the selection range actually mutated.
// Listeners still fire unconditionally — useHasSelection() snapshots
// through React, which dedupes via Object.is on the boolean value.
const sig = selectionSignature(this.selection)
if (active !== this.selectionWasActive) {
this.selectionWasActive = active
if (sig !== this.lastSelectionSignature) {
this.lastSelectionSignature = sig
this.selectionVersion += 1
}
for (const cb of this.selectionListeners) {
cb()
}
for (const cb of this.selectionListeners) {
cb()
}
}

View file

@ -799,6 +799,20 @@ export function hasSelection(s: SelectionState): boolean {
return s.anchor !== null && s.focus !== null
}
/**
* Stable fingerprint of the user-visible selection state. Used by Ink
* to skip incrementing the mutation counter when notifySelectionChange()
* fires without an actual change to anchor/focus/isDragging protects
* version-based subscribers (copy-on-select) from re-running for the
* same stable selection.
*/
export function selectionSignature(s: SelectionState): string {
const a = s.anchor ? `${s.anchor.row},${s.anchor.col}` : 'null'
const f = s.focus ? `${s.focus.row},${s.focus.col}` : 'null'
return `${a}|${f}|${s.isDragging ? 1 : 0}`
}
/**
* Normalized selection bounds: start is always before end in reading order.
* Returns null if no active selection.