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

@ -1,4 +1,4 @@
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { AlternateScreen, Box, NoSelect, ScrollBox, stringWidth, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { Fragment, memo, useMemo, useRef } from 'react'
@ -124,8 +124,10 @@ const ComposerPane = memo(function ComposerPane({
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const pw = 2
const inputColumns = stableComposerColumns(composer.cols, pw)
const promptText = sh ? '$' : ui.theme.brand.prompt
const promptLabel = `${promptText} `
const promptWidth = Math.max(1, stringWidth(promptLabel))
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
const inputHeight = inputVisualHeight(composer.input, inputColumns)
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
@ -146,7 +148,7 @@ const ComposerPane = memo(function ComposerPane({
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - pw)
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth)
}
// Spacer rows live on a different vertical origin; only the column is
@ -158,7 +160,7 @@ const ComposerPane = memo(function ComposerPane({
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - pw)
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
}
const endInputDrag = () => inputMouseRef.current?.end()
@ -183,13 +185,13 @@ const ComposerPane = memo(function ComposerPane({
/>
{ui.bgTasks.size > 0 && (
<Text color={ui.theme.color.dim}>
<Text color={ui.theme.color.muted}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.dim} wrap="truncate-end">
<Text color={ui.theme.color.muted} wrap="truncate-end">
<Text color={ui.theme.color.label}> </Text>
{status.stickyPrompt}
@ -214,21 +216,21 @@ const ComposerPane = memo(function ComposerPane({
<>
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={2}>
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
<Box width={promptWidth}>
<Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
</Box>
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text>
<Text color={ui.theme.color.text}>{line || ' '}</Text>
</Box>
))}
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
<Box width={pw}>
<Box width={promptWidth}>
{sh ? (
<Text color={ui.theme.color.shellDollar}>$ </Text>
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
) : (
<Text bold color={ui.theme.color.prompt}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
{composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
</Text>
)}
</Box>
@ -254,7 +256,7 @@ const ComposerPane = memo(function ComposerPane({
)}
</Box>
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}> {ui.status}</Text>}
{!composer.empty && !ui.sid && <Text color={ui.theme.color.muted}> {ui.status}</Text>}
<StatusRulePane at="bottom" composer={composer} status={status} />
</NoSelect>
@ -319,6 +321,7 @@ export const AppLayout = memo(function AppLayout({
transcript
}: AppLayoutProps) {
const overlay = useStore($overlayState)
const ui = useStore($uiState)
// Inline mode skips AlternateScreen so the host terminal's native
// scrollback captures rows scrolled off the top; composer + progress
@ -359,7 +362,7 @@ export const AppLayout = memo(function AppLayout({
{SHOW_FPS && (
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
<FpsOverlay />
<FpsOverlay t={ui.theme} />
</Box>
)}
</>