diff --git a/windows/assets/dream-skin.css b/windows/assets/dream-skin.css index 939ce094..d52f1be4 100644 --- a/windows/assets/dream-skin.css +++ b/windows/assets/dream-skin.css @@ -1317,3 +1317,452 @@ html[data-dream-skin="active"] [data-ds-part="message"] { border-radius: 16px; } } } } + +/* Codex 26.x gives the composer submit button a literal foreground fill. + That becomes a white disk when the button loses focus, so keep the action + on the same themed glass/accent scale in every interaction state. */ +html[data-dream-skin="active"] + :is( + .composer-surface-chrome, + [class*="_ComposerLayoutRoot_"], + [data-ds-part="composer"] + ) + :is( + button[type="submit"], + button[class~="bg-token-foreground"], + button[class*="bg-primary-solid"], + button[aria-label="发送"], + button[aria-label="停止"], + button[aria-label*="Send" i], + button[aria-label*="Stop" i] + ) { + background: rgb(var(--ds-accent-rgb) / .72) !important; + background-color: rgb(var(--ds-accent-rgb) / .72) !important; + border: 1px solid rgb(var(--ds-text-rgb) / .34) !important; + color: var(--ds-text) !important; + opacity: 1 !important; + box-shadow: 0 3px 10px rgb(var(--ds-bg-rgb) / .30) !important; +} +html[data-dream-skin="active"] + :is( + .composer-surface-chrome, + [class*="_ComposerLayoutRoot_"], + [data-ds-part="composer"] + ) + :is( + button[type="submit"], + button[class~="bg-token-foreground"], + button[class*="bg-primary-solid"], + button[aria-label="发送"], + button[aria-label="停止"], + button[aria-label*="Send" i], + button[aria-label*="Stop" i] + ):hover, +html[data-dream-skin="active"] + :is( + .composer-surface-chrome, + [class*="_ComposerLayoutRoot_"], + [data-ds-part="composer"] + ) + :is( + button[type="submit"], + button[class~="bg-token-foreground"], + button[class*="bg-primary-solid"], + button[aria-label="发送"], + button[aria-label="停止"], + button[aria-label*="Send" i], + button[aria-label*="Stop" i] + ):focus-visible { + background: rgb(var(--ds-accent-rgb) / .94) !important; + background-color: rgb(var(--ds-accent-rgb) / .94) !important; + color: var(--ds-text) !important; + box-shadow: 0 3px 12px rgb(var(--ds-accent-rgb) / .42) !important; +} +html[data-dream-skin="active"] + :is( + .composer-surface-chrome, + [class*="_ComposerLayoutRoot_"], + [data-ds-part="composer"] + ) + :is( + button[type="submit"], + button[class~="bg-token-foreground"], + button[class*="bg-primary-solid"], + button[aria-label="发送"], + button[aria-label="停止"], + button[aria-label*="Send" i], + button[aria-label*="Stop" i] + ):disabled { + background: rgb(var(--ds-panel-2-rgb) / .72) !important; + background-color: rgb(var(--ds-panel-2-rgb) / .72) !important; + border-color: rgb(var(--ds-line-rgb) / .68) !important; + color: rgb(var(--ds-muted-rgb) / .92) !important; + opacity: 1 !important; + box-shadow: none !important; +} + +/* Keep native text selection and selected dialog controls from reintroducing + a pale/white slab over the wallpaper. */ +html[data-dream-skin="active"] ::selection { + background: rgb(var(--ds-accent-rgb) / .52) !important; + color: var(--ds-text) !important; + text-shadow: none !important; +} +html[data-dream-skin="active"] + :is([aria-selected="true"], [data-state="selected"], [data-selected="true"]) { + background: rgb(var(--ds-accent-rgb) / .30) !important; + background-color: rgb(var(--ds-accent-rgb) / .30) !important; + border-color: rgb(var(--ds-accent-rgb) / .56) !important; + color: var(--ds-text) !important; + text-shadow: 0 1px 3px rgb(var(--ds-bg-rgb) / .42) !important; +} + +/* Dialog actions must keep their fill when the modal loses focus. Newer + builds use literal white foreground utilities for the confirm action. */ +html[data-dream-skin="active"] + :is([role="dialog"], [data-radix-dialog-content], [class*="DialogContent"], [class*="_Dialog_"]) + :is(button, [role="button"]) { + background: rgb(var(--ds-panel-2-rgb) / .78) !important; + background-color: rgb(var(--ds-panel-2-rgb) / .78) !important; + border: 1px solid rgb(var(--ds-accent-rgb) / .52) !important; + color: var(--ds-text) !important; + opacity: 1 !important; + text-shadow: 0 1px 2px rgb(var(--ds-bg-rgb) / .38) !important; +} +html[data-dream-skin="active"] + :is([role="dialog"], [data-radix-dialog-content], [class*="DialogContent"], [class*="_Dialog_"]) + :is(button, [role="button"]):hover, +html[data-dream-skin="active"] + :is([role="dialog"], [data-radix-dialog-content], [class*="DialogContent"], [class*="_Dialog_"]) + :is(button, [role="button"]):focus-visible { + background: rgb(var(--ds-accent-rgb) / .88) !important; + background-color: rgb(var(--ds-accent-rgb) / .88) !important; + color: var(--ds-text) !important; +} +html[data-dream-skin="active"] + :is([role="dialog"], [data-radix-dialog-content], [class*="DialogContent"], [class*="_Dialog_"]) + :is(button, [role="button"]):disabled { + background: rgb(var(--ds-panel-2-rgb) / .54) !important; + background-color: rgb(var(--ds-panel-2-rgb) / .54) !important; + border-color: rgb(var(--ds-line-rgb) / .52) !important; + color: rgb(var(--ds-muted-rgb) / .88) !important; +} + +/* The message-edit dialog uses the same foreground token as the composer, but + it lives outside the composer shell. Keep its send action themed too. */ +html[data-dream-skin="active"] + :is([data-dream-skin-dialog-send="true"], [role="dialog"], [aria-modal="true"], [class*="Dialog"], [class*="dialog"]) + :is(button[type="submit"], button[class~="bg-token-foreground"], button[class*="bg-primary-solid"], button[aria-label="发送"], button[aria-label*="Send" i]) { + background: rgb(var(--ds-accent-rgb) / .72) !important; + background-color: rgb(var(--ds-accent-rgb) / .72) !important; + border: 1px solid rgb(var(--ds-text-rgb) / .34) !important; + color: var(--ds-text) !important; + opacity: 1 !important; + box-shadow: 0 3px 10px rgb(var(--ds-bg-rgb) / .30) !important; +} +html[data-dream-skin="active"] + [data-dream-skin-dialog-send="true"] { + background: rgb(var(--ds-accent-rgb) / .72) !important; + background-color: rgb(var(--ds-accent-rgb) / .72) !important; + border: 1px solid rgb(var(--ds-text-rgb) / .34) !important; + color: var(--ds-text) !important; + opacity: 1 !important; +} + +/* Toasts used for archive/delete/undo feedback are separate from dialogs. + Keep their surface readable even when Codex resolves its native toast token + to a white panel under a translucent Dream Skin. */ +html[data-dream-skin="active"] + :is( + [data-dream-skin-notification-toast="true"], + [data-radix-toast-root], + [data-sonner-toast], + [role="alert"], + [class*="Toast"], + [class*="toast"] + ) { + background: rgb(var(--ds-panel-2-rgb) / .92) !important; + background-color: rgb(var(--ds-panel-2-rgb) / .92) !important; + border: 1px solid rgb(var(--ds-accent-rgb) / .58) !important; + border-radius: 14px !important; + box-shadow: 0 10px 26px rgb(var(--ds-bg-rgb) / .34) !important; + color: var(--ds-text) !important; + opacity: 1 !important; + text-shadow: 0 1px 2px rgb(var(--ds-bg-rgb) / .42) !important; +} +html[data-dream-skin="active"] + :is( + [data-dream-skin-notification-toast="true"], + [data-radix-toast-root], + [data-sonner-toast], + [role="alert"], + [class*="Toast"], + [class*="toast"] + ) + :is(button, [role="button"]) { + background: rgb(var(--ds-panel-rgb) / .54) !important; + background-color: rgb(var(--ds-panel-rgb) / .54) !important; + border: 1px solid rgb(var(--ds-accent-rgb) / .52) !important; + border-radius: 10px !important; + color: var(--ds-text) !important; + opacity: 1 !important; + text-shadow: 0 1px 2px rgb(var(--ds-bg-rgb) / .42) !important; +} +html[data-dream-skin="active"] + :is( + [data-dream-skin-notification-toast="true"], + [data-radix-toast-root], + [data-sonner-toast], + [role="alert"], + [class*="Toast"], + [class*="toast"] + ) + :is(button, [role="button"]):hover, +html[data-dream-skin="active"] + :is( + [data-dream-skin-notification-toast="true"], + [data-radix-toast-root], + [data-sonner-toast], + [role="alert"], + [class*="Toast"], + [class*="toast"] + ) + :is(button, [role="button"]):focus-visible { + background: rgb(var(--ds-accent-rgb) / .82) !important; + background-color: rgb(var(--ds-accent-rgb) / .82) !important; + color: var(--ds-text) !important; +} + +/* Codex's text-selection action bar is a transient portal. Cover the stable + toolbar semantics and generated class families used by current builds. */ +html[data-dream-skin="active"] + :is( + [data-dream-skin-selection-toolbar="true"], + [role="toolbar"], + [class*="SelectionToolbar"], + [class*="selection-toolbar"], + [class*="ContextualToolbar"], + [class*="contextual-toolbar"] + ) { + background: rgb(var(--ds-panel-2-rgb) / .88) !important; + background-color: rgb(var(--ds-panel-2-rgb) / .88) !important; + border: 1px solid rgb(var(--ds-accent-rgb) / .58) !important; + border-radius: 14px !important; + box-shadow: 0 10px 26px rgb(var(--ds-bg-rgb) / .34) !important; + color: var(--ds-text) !important; + opacity: 1 !important; +} +html[data-dream-skin="active"] + :is( + [data-dream-skin-selection-toolbar="true"], + [role="toolbar"], + [class*="SelectionToolbar"], + [class*="selection-toolbar"], + [class*="ContextualToolbar"], + [class*="contextual-toolbar"] + ) + :is(button, [role="button"]) { + background: transparent !important; + background-color: transparent !important; + border-color: rgb(var(--ds-line-rgb) / .34) !important; + color: var(--ds-text) !important; + opacity: 1 !important; + text-shadow: 0 1px 2px rgb(var(--ds-bg-rgb) / .46) !important; +} +html[data-dream-skin="active"] + :is( + [data-dream-skin-selection-toolbar="true"], + [role="toolbar"], + [class*="SelectionToolbar"], + [class*="selection-toolbar"], + [class*="ContextualToolbar"], + [class*="contextual-toolbar"] + ) + :is(button, [role="button"]):hover { + background: rgb(var(--ds-accent-rgb) / .22) !important; + background-color: rgb(var(--ds-accent-rgb) / .22) !important; + color: var(--ds-text) !important; +} + +/* Codex 26.803 file-change cards use utility tokens such as + bg-surface-elevated-secondary/50. Those tokens can resolve to a pale, + half-transparent surface, which washes out the path and +/- counts over + the wallpaper. Keep the card and its hover header on the same readable + glass material as the rest of the themed conversation. */ +html[data-dream-skin="active"] + :is( + [class~="bg-surface-elevated-secondary/50"], + [class~="extension:bg-background-primary-soft/50"] + ) { + background: rgb(var(--ds-panel-2-rgb) / .88) !important; + background-color: rgb(var(--ds-panel-2-rgb) / .88) !important; + border-color: rgb(var(--ds-accent-rgb) / .48) !important; + box-shadow: 0 8px 22px rgb(var(--ds-bg-rgb) / .22) !important; + color: var(--ds-text) !important; + filter: none !important; + opacity: 1 !important; +} +html[data-dream-skin="active"] + :is( + [class~="bg-surface-elevated-secondary/50"], + [class~="extension:bg-background-primary-soft/50"] + ) + :is([class*="turn-diff-header"], [class*="turn-diff"] [class*="header"]) { + background: transparent !important; + background-color: transparent !important; + color: var(--ds-text) !important; + filter: none !important; + opacity: 1 !important; +} +html[data-dream-skin="active"] + :is( + [class~="bg-surface-elevated-secondary/50"], + [class~="extension:bg-background-primary-soft/50"] + ) + :is(p, span, button, [role="button"], a, svg, svg *, [class*="text-"]) { + color: var(--ds-text) !important; + opacity: 1 !important; + text-shadow: 0 1px 2px rgb(var(--ds-bg-rgb) / .34) !important; +} +html[data-dream-skin="active"] + :is( + [class~="bg-surface-elevated-secondary/50"], + [class~="extension:bg-background-primary-soft/50"] + ) + :is(button, [role="button"]) { + background: rgb(var(--ds-panel-rgb) / .56) !important; + background-color: rgb(var(--ds-panel-rgb) / .56) !important; + border-color: rgb(var(--ds-accent-rgb) / .52) !important; + color: var(--ds-text) !important; + box-shadow: 0 2px 8px rgb(var(--ds-bg-rgb) / .16) !important; +} +html[data-dream-skin="active"] + :is( + [class~="bg-surface-elevated-secondary/50"], + [class~="extension:bg-background-primary-soft/50"] + ) + :is(button, [role="button"]):hover, +html[data-dream-skin="active"] + :is( + [class~="bg-surface-elevated-secondary/50"], + [class~="extension:bg-background-primary-soft/50"] + ) + :is(button, [role="button"]):focus-visible { + background: rgb(var(--ds-accent-rgb) / .78) !important; + background-color: rgb(var(--ds-accent-rgb) / .78) !important; + color: var(--ds-text) !important; +} + +/* The expanded file preview is a shadow-root-backed diffs-container. Feed it + both the surface and editor tokens, and explicitly disable inherited fade + or blur so code remains sharp while the file row is hovered. */ +html[data-dream-skin="active"] diffs-container { + --color-token-main-surface-primary: #ffffff !important; + --color-token-main-surface-secondary: #ffffff !important; + --color-token-text-primary: var(--ds-text) !important; + --color-token-text-secondary: var(--ds-muted) !important; + --color-token-text-link-foreground: var(--ds-accent) !important; + --color-token-editor-foreground: var(--ds-text) !important; + --codex-base-surface: #ffffff !important; + --codex-base-ink: #1f2937 !important; + --vscode-editor-background: #ffffff !important; + --vscode-editor-foreground: #1f2937 !important; + --diffs-light-bg: #ffffff !important; + --diffs-dark-bg: #ffffff !important; + --diffs-addition-base: #65d6ad !important; + --diffs-deletion-base: #ff8e9e !important; + background: #ffffff !important; + background-color: #ffffff !important; + border: 1px solid #d7dee7 !important; + box-shadow: 0 8px 22px rgb(var(--ds-bg-rgb) / .22) !important; + color: #1f2937 !important; + filter: none !important; + opacity: 1 !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; +} +html[data-dream-skin="active"] diffs-container + :is(pre, code, [class*="code"], [class*="Code"], [class*="editor"], [class*="Editor"]) { + background: transparent !important; + background-color: transparent !important; + color: #1f2937 !important; + filter: none !important; + opacity: 1 !important; + text-shadow: none !important; +} + +/* The hover preview is a Radix tooltip surface, not only a diffs-container. + Its current Codex markup uses group/diff-preview plus the un-suffixed + bg-surface-elevated-secondary token; cover those concrete surfaces so the + editor cannot fall back to a white panel. */ +html[data-dream-skin="active"] [class*="diff-preview"] { + --color-token-main-surface-primary: var(--ds-panel-2) !important; + --color-token-main-surface-secondary: var(--ds-panel) !important; + --color-token-text-primary: var(--ds-text) !important; + --color-token-text-secondary: var(--ds-muted) !important; + --color-token-text-tertiary: var(--ds-secondary) !important; + --color-token-foreground: var(--ds-text) !important; + --color-token-description-foreground: var(--ds-muted) !important; + --vscode-editor-background: var(--ds-panel-2) !important; + --vscode-editor-foreground: var(--ds-text) !important; + background: rgb(var(--ds-bg-rgb) / .92) !important; + background-color: rgb(var(--ds-bg-rgb) / .92) !important; + border-color: rgb(var(--ds-accent-rgb) / .62) !important; + color: var(--ds-text) !important; + filter: none !important; + opacity: 1 !important; + text-shadow: none !important; +} +html[data-dream-skin="active"] [class*="diff-preview"] + :is([class~="bg-surface-elevated-secondary"], [class~="extension:bg-primary-soft"]) { + background: rgb(var(--ds-panel-2-rgb) / .96) !important; + background-color: rgb(var(--ds-panel-2-rgb) / .96) !important; + border-color: rgb(var(--ds-accent-rgb) / .54) !important; + color: var(--ds-text) !important; +} +html[data-dream-skin="active"] [class*="diff-preview"] + :is(p, span, div, button, [role="button"], svg, svg *):not(pre *):not(code *) { + color: var(--ds-text) !important; + opacity: 1 !important; + text-shadow: none !important; +} +html[data-dream-skin="active"] [class*="diff-preview"] + :is(pre, code, [class*="code"], [class*="Code"], [class*="editor"], [class*="Editor"]) { + background: transparent !important; + background-color: transparent !important; + color: var(--ds-text) !important; + filter: none !important; + opacity: 1 !important; +} +html[data-dream-skin="active"] [class*="diff-preview"] + :is([class~="max-h-96"], [class*="composer-diff-simple-line"]) { + background: #ffffff !important; + background-color: #ffffff !important; + color: #1f2937 !important; + border-color: #d7dee7 !important; + opacity: 1 !important; + text-shadow: none !important; +} +html[data-dream-skin="active"] [class*="diff-preview"] :is(pre, code) { + background: transparent !important; + background-color: transparent !important; + color: #1f2937 !important; + text-shadow: none !important; +} +html[data-dream-skin="active"] [class*="diff-preview"] [class~="max-h-96"] :is(pre, code) { + color: #1f2937 !important; + text-shadow: none !important; +} +html[data-dream-skin="active"] [class*="diff-preview"][class*="diff-preview"] [class~="max-h-96"] { + color: #1f2937 !important; + text-shadow: none !important; +} + +/* Keep file-change preview surfaces sharp while hovering the edited file. */ +html[data-dream-skin="active"] + :is(diffs-container, [class*="diff"], [class*="Diff"], [class*="preview"], [class*="Preview"]) { + filter: none !important; + opacity: 1 !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; +} diff --git a/windows/assets/renderer-inject.js b/windows/assets/renderer-inject.js index 6076e7ab..64d64ffa 100644 --- a/windows/assets/renderer-inject.js +++ b/windows/assets/renderer-inject.js @@ -58,6 +58,7 @@ let rootObserver = null; let partObserver = null; let bodyReadyHandler = null; + let selectionChangeHandler = null; let styleMode = null; let styleNode = null; let styleSheet = null; @@ -603,6 +604,150 @@ } }; + const paintSelectionToolbarNow = () => { + const marker = '[data-dream-skin-selection-toolbar="true"]'; + for (const previous of document.querySelectorAll(marker)) { + previous.removeAttribute('data-dream-skin-selection-toolbar'); + for (const property of ['background', 'background-color', 'border', 'border-radius', 'box-shadow', 'color', 'opacity']) { + previous.style.removeProperty(property); + } + } + const labels = [ + /添加到对话|add to chat/i, + /更多详情|more details/i, + /在侧边聊天中提问|ask in side chat/i, + ]; + const controls = [...document.querySelectorAll('button, [role="button"]')].filter((control) => { + const rect = control.getBoundingClientRect(); + const text = String(control.innerText || control.getAttribute('aria-label') || '').trim(); + return rect.width > 0 && rect.height > 0 && labels.some((pattern) => pattern.test(text)); + }); + if (controls.length < 2) return; + for (let node = controls[0].parentElement; node && node !== document.body; node = node.parentElement) { + if (!controls.every((control) => node.contains(control))) continue; + const rect = node.getBoundingClientRect(); + if (rect.width < 180 || rect.width > 900 || rect.height < 28 || rect.height > 140) continue; + node.setAttribute('data-dream-skin-selection-toolbar', 'true'); + node.style.setProperty('background', 'rgb(var(--ds-panel-2-rgb) / .88)', 'important'); + node.style.setProperty('background-color', 'rgb(var(--ds-panel-2-rgb) / .88)', 'important'); + node.style.setProperty('border', '1px solid rgb(var(--ds-accent-rgb) / .58)', 'important'); + node.style.setProperty('border-radius', '14px', 'important'); + node.style.setProperty('box-shadow', '0 10px 26px rgb(var(--ds-bg-rgb) / .34)', 'important'); + node.style.setProperty('color', 'var(--ds-text)', 'important'); + node.style.setProperty('opacity', '1', 'important'); + for (const control of node.querySelectorAll('button, [role="button"]')) { + control.style.setProperty('background', 'transparent', 'important'); + control.style.setProperty('background-color', 'transparent', 'important'); + control.style.setProperty('color', 'var(--ds-text)', 'important'); + control.style.setProperty('opacity', '1', 'important'); + } + return; + } + }; + + const paintNotificationToastsNow = () => { + const marker = '[data-dream-skin-notification-toast="true"]'; + const properties = [ + 'background', 'background-color', 'border', 'border-radius', + 'box-shadow', 'color', 'opacity', 'text-shadow', + ]; + for (const previous of document.querySelectorAll(marker)) { + previous.removeAttribute('data-dream-skin-notification-toast'); + for (const property of properties) previous.style.removeProperty(property); + for (const control of previous.querySelectorAll('button, [role="button"]')) { + for (const property of properties) control.style.removeProperty(property); + } + } + const candidates = document.querySelectorAll( + '[data-radix-toast-root], [data-sonner-toast], [role="alert"], [class*="Toast"], [class*="toast"]', + ); + for (const node of candidates) { + const rect = node.getBoundingClientRect(); + if (rect.width < 180 || rect.width > 900 || rect.height < 24 || rect.height > 180 || rect.top > 260) continue; + const style = getComputedStyle(node); + const inToastViewport = Boolean(node.closest?.('[data-radix-toast-viewport], [data-sonner-toaster]')); + const fixed = style.position === 'fixed' || inToastViewport || node.matches?.('[data-radix-toast-root], [data-sonner-toast], [role="alert"]'); + if (!fixed) continue; + node.setAttribute('data-dream-skin-notification-toast', 'true'); + node.style.setProperty('background', 'rgb(var(--ds-panel-2-rgb) / .92)', 'important'); + node.style.setProperty('background-color', 'rgb(var(--ds-panel-2-rgb) / .92)', 'important'); + node.style.setProperty('border', '1px solid rgb(var(--ds-accent-rgb) / .58)', 'important'); + node.style.setProperty('border-radius', '14px', 'important'); + node.style.setProperty('box-shadow', '0 10px 26px rgb(var(--ds-bg-rgb) / .34)', 'important'); + node.style.setProperty('color', 'var(--ds-text)', 'important'); + node.style.setProperty('opacity', '1', 'important'); + node.style.setProperty('text-shadow', '0 1px 2px rgb(var(--ds-bg-rgb) / .42)', 'important'); + for (const control of node.querySelectorAll('button, [role="button"]')) { + control.style.setProperty('background', 'rgb(var(--ds-panel-rgb) / .54)', 'important'); + control.style.setProperty('background-color', 'rgb(var(--ds-panel-rgb) / .54)', 'important'); + control.style.setProperty('border', '1px solid rgb(var(--ds-accent-rgb) / .52)', 'important'); + control.style.setProperty('border-radius', '10px', 'important'); + control.style.setProperty('color', 'var(--ds-text)', 'important'); + control.style.setProperty('opacity', '1', 'important'); + } + } + const dynamicTopToasts = [...document.querySelectorAll('body *')] + .filter((node) => { + const rect = node.getBoundingClientRect(); + const text = String(node.innerText || node.textContent || '').trim().replace(/\s+/g, ' '); + return rect.width >= 180 && rect.width <= 900 && rect.height >= 24 && rect.height <= 180 + && rect.top <= 260 && text.length <= 240 + && /已归档|归档的聊天|archived|撤销|undo|对话中途切换模型|切换模型会降低性能|model switch|switching models|reduces performance/i.test(text) + && node.querySelector?.('button, [role="button"]'); + }) + .sort((left, right) => { + const a = left.getBoundingClientRect(); + const b = right.getBoundingClientRect(); + return a.width * a.height - b.width * b.height; + }); + for (const node of dynamicTopToasts.slice(0, 2)) { + for (const surface of [node, node.parentElement, node.parentElement?.parentElement]) { + if (!surface || surface === document.body || surface === document.documentElement) continue; + const rect = surface.getBoundingClientRect(); + if (rect.width < 180 || rect.width > 900 || rect.height < 24 || rect.height > 180 || rect.top > 260) continue; + surface.setAttribute('data-dream-skin-notification-toast', 'true'); + surface.style.setProperty('background', 'rgb(var(--ds-panel-2-rgb) / .92)', 'important'); + surface.style.setProperty('background-color', 'rgb(var(--ds-panel-2-rgb) / .92)', 'important'); + surface.style.setProperty('border', '1px solid rgb(var(--ds-accent-rgb) / .58)', 'important'); + surface.style.setProperty('border-radius', '14px', 'important'); + surface.style.setProperty('box-shadow', '0 10px 26px rgb(var(--ds-bg-rgb) / .34)', 'important'); + surface.style.setProperty('color', 'var(--ds-text)', 'important'); + surface.style.setProperty('opacity', '1', 'important'); + surface.style.setProperty('text-shadow', '0 1px 2px rgb(var(--ds-bg-rgb) / .42)', 'important'); + for (const control of surface.querySelectorAll('button, [role="button"]')) { + control.style.setProperty('background', 'rgb(var(--ds-panel-rgb) / .54)', 'important'); + control.style.setProperty('background-color', 'rgb(var(--ds-panel-rgb) / .54)', 'important'); + control.style.setProperty('border', '1px solid rgb(var(--ds-accent-rgb) / .52)', 'important'); + control.style.setProperty('border-radius', '10px', 'important'); + control.style.setProperty('color', 'var(--ds-text)', 'important'); + control.style.setProperty('opacity', '1', 'important'); + } + } + } + }; + + const paintDialogSendButtonsNow = () => { + const marker = '[data-dream-skin-dialog-send="true"]'; + for (const previous of document.querySelectorAll(marker)) { + previous.removeAttribute('data-dream-skin-dialog-send'); + for (const property of ['background', 'background-color', 'border', 'box-shadow', 'color', 'opacity']) { + previous.style.removeProperty(property); + } + } + for (const control of document.querySelectorAll('button, [role="button"]')) { + const text = String(control.innerText || control.getAttribute('aria-label') || '').trim(); + const rect = control.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0 || !/^(发送|send)$/i.test(text)) continue; + control.setAttribute('data-dream-skin-dialog-send', 'true'); + control.style.setProperty('background', 'rgb(var(--ds-accent-rgb) / .72)', 'important'); + control.style.setProperty('background-color', 'rgb(var(--ds-accent-rgb) / .72)', 'important'); + control.style.setProperty('border', '1px solid rgb(var(--ds-text-rgb) / .34)', 'important'); + control.style.setProperty('box-shadow', '0 3px 10px rgb(var(--ds-bg-rgb) / .30)', 'important'); + control.style.setProperty('color', 'var(--ds-text)', 'important'); + control.style.setProperty('opacity', '1', 'important'); + } + }; + installStyle(); const applyRootState = (root) => { @@ -614,6 +759,13 @@ setStyleProperty(root, "--dream-skin-art", `url("${artUrl}")`); applyTheme(root, shell); applyArtMetadata(root); + if (!selectionChangeHandler) { + selectionChangeHandler = () => paintSelectionToolbarNow(); + document.addEventListener("selectionchange", selectionChangeHandler); + } + paintSelectionToolbarNow(); + paintNotificationToastsNow(); + paintDialogSendButtonsNow(); return shell; }; @@ -798,6 +950,10 @@ if (bodyReadyHandler && typeof document.removeEventListener === "function") { document.removeEventListener("DOMContentLoaded", bodyReadyHandler); } + if (selectionChangeHandler && typeof document.removeEventListener === "function") { + document.removeEventListener("selectionchange", selectionChangeHandler); + selectionChangeHandler = null; + } if (state?.timer) clearInterval(state.timer); if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout); if (analysisTimer) clearTimeout(analysisTimer); @@ -844,7 +1000,12 @@ // SPA route changes are observable as DOM mutations even when Chromium's // Navigation API emits no event. Keep verification scope and public parts // derived from the same post-mutation tree. - partObserver = new MutationObserver(() => scheduleEnsure({ scope: true, parts: true }, 80)); + partObserver = new MutationObserver(() => { + paintSelectionToolbarNow(); + paintNotificationToastsNow(); + paintDialogSendButtonsNow(); + scheduleEnsure({ scope: true, parts: true }, 80); + }); } let mediaQuery = null; diff --git a/windows/references/pet-extension-contract.md b/windows/references/pet-extension-contract.md new file mode 100644 index 00000000..443ad7d9 --- /dev/null +++ b/windows/references/pet-extension-contract.md @@ -0,0 +1,41 @@ +# Optional pet state extension + +The Windows skin injector does not require a desktop pet. Pet integrations can +use `windows/scripts/` as a small arbitration layer, with two source modes: + +- `dom`: no app-server dependency; suitable for selected-thread, running-marker, + visible approval, and visible user-input behavior. +- `dom-plus`: structured event mode for custom pets that need detailed states + such as `commandExecution`, `fileRead`, `fileChange`, `mcpToolCall`, + `webSearch`, `plan`, `reasoning`, `completed`, `failed`, and `aborted`. +The legacy inputs `auto` and `app-server` are accepted by the JavaScript +normalizer as aliases for `dom-plus`, but they are not product modes. + +The intended adapter boundary is: + +1. A Codex UI adapter reports the selected sidebar row, its conversation ID, + and whether the row has the visible running marker. +2. An optional app-server adapter supplies JSON-RPC notifications to + `createPetStateBridge().ingestAppServer(message)` or includes them in + `sync({ appServerEvents })`. +3. `createPetStateBridge()` arbitrates both sources and emits normalized pet + states to the pet package. + +The selected row and its running marker are always the activity gate. A stale +task completion or an event belonging to another conversation cannot revive a +conversation that is not selected or no longer has the running marker. A +visible approval or user-input card takes precedence over a generic reasoning +event from app-server. + +DOM alone cannot reliably reproduce every structured execution item. The +`dom-plus` is intentionally preserved as an optional capability for enhanced +pets; it is not required by the core theme injector. The pet package owns the +app-server process/transport lifecycle. If that process cannot be started, +the enhanced mode reports the failure instead of silently pretending that +structured events are available. + +The core project intentionally does not bundle pet art or start a pet process. +A pet package can provide its own spritesheet, state map, renderer, and window +lifecycle while depending on this contract. This keeps optional desktop pet +behavior separate from theme injection while retaining the detailed event +source needed by advanced custom pets. diff --git a/windows/scripts/codex-state-bridge.mjs b/windows/scripts/codex-state-bridge.mjs new file mode 100644 index 00000000..733820cc --- /dev/null +++ b/windows/scripts/codex-state-bridge.mjs @@ -0,0 +1,196 @@ +/** + * Platform-neutral DOM state contract for optional Codex pet integrations. + * + * The caller provides DOM snapshots and, when enabled, normalized app-server + * messages. The transport and process lifecycle remain optional to the pet + * integration rather than becoming a core skin dependency. + */ + +export const STATE_SOURCE_MODES = Object.freeze(["dom", "dom-plus"]); + +export function normalizeStateSource(value) { + const normalized = String(value || "dom").trim().toLowerCase(); + if (normalized === "dom-plus" || normalized === "auto" || normalized === "app-server") return "dom-plus"; + return "dom"; +} + +export function createStateSourcePolicy(stateSource = "dom") { + const mode = normalizeStateSource(stateSource); + return Object.freeze({ + mode, + allowDom: true, + allowAppServer: mode === "dom-plus", + requireAppServer: mode === "dom-plus", + fallbackToDom: false, + }); +} + +function extractThreadId(value) { + const raw = String(value || ""); + const uuid = raw.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i); + return uuid?.[0] || (raw.startsWith("client-new-thread:") ? raw : null); +} + +function eventThreadId(message) { + const params = message?.params || {}; + return extractThreadId( + params.threadId + || params.conversationId + || params.turn?.threadId + || params.item?.threadId + || params.item?.conversationId, + ); +} + +function mapItemType(itemType, item = {}) { + const type = String(itemType || ""); + if (type === "commandExecution") { + const command = String(item.command || item.commandLine || item.input || ""); + return /(?:^|[\\/ ])(?:cat|type|Get-Content|rg|findstr|Select-String)\b/i.test(command) + ? "fileRead" + : "commandExecution"; + } + if (["fileRead", "fileChange", "mcpToolCall", "webSearch", "plan", "reasoning"].includes(type)) return type; + return null; +} + +/** + * Resolve the selected sidebar row before consulting visible activity. + * A row without its running marker is never revived by stale task activity. + */ +export function resolveSelectedThread(uiProbe, knownThreadIds = new Set()) { + const probe = uiProbe && typeof uiProbe === "object" ? uiProbe : {}; + const details = Array.isArray(probe.details) ? probe.details : []; + const selected = details[0] || null; + const selectedRaw = String(selected?.id || ""); + const uuid = selectedRaw.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i); + const selectedId = uuid?.[0] || (selectedRaw.startsWith("client-new-thread:") ? selectedRaw : null); + const candidates = Array.isArray(probe.ids) ? probe.ids : []; + const candidateId = candidates.find((id) => knownThreadIds.has(id)) || null; + return Object.freeze({ + threadId: selectedId || candidateId, + running: selected?.running === true, + title: String(selected?.title || ""), + }); +} + +export function domActivityToPetState(activitySignal) { + if (activitySignal === "waitingOnApproval") return "waitingOnApproval"; + if (activitySignal === "waitingOnUserInput") return "waitingOnUserInput"; + if (activitySignal === "active") return "reasoning"; + return null; +} + +export function appServerFlagsToPetState(flags) { + const activeFlags = Array.isArray(flags) ? flags.map(String) : []; + const known = [ + "waitingOnApproval", + "waitingOnUserInput", + "commandExecution", + "fileRead", + "fileChange", + "mcpToolCall", + "webSearch", + "plan", + "reasoning", + ]; + return known.find((state) => activeFlags.includes(state)) || null; +} + +/** Normalize one app-server notification without owning its transport. */ +export function appServerEventToPetState(message, currentThreadId = null) { + const method = String(message?.method || ""); + const params = message?.params || {}; + const threadId = eventThreadId(message); + if (currentThreadId && threadId && threadId !== currentThreadId) return null; + + if ([ + "item/commandExecution/requestApproval", + "item/permissions/requestApproval", + "item/fileChange/requestApproval", + "execCommandApproval", + "applyPatchApproval", + ].includes(method)) { + return { state: "waitingOnApproval", threadId, terminal: false }; + } + + if (method === "thread/status/changed") { + const flagged = appServerFlagsToPetState(params.status?.activeFlags); + if (flagged) return { state: flagged, threadId, terminal: false }; + if (params.status?.type === "active") return { state: "reasoning", threadId, terminal: false }; + if (params.status?.type === "systemError") return { state: "failed", threadId, terminal: true }; + return null; + } + + if (method === "turn/started") return { state: "reasoning", threadId, terminal: false }; + if (method === "turn/completed" || method === "task_complete" || method === "task/completed") { + const status = String(params.turn?.status || params.status || "completed").toLowerCase(); + if (status === "failed" || status === "error") return { state: "failed", threadId, terminal: true }; + if (status === "interrupted" || status === "aborted" || status === "cancelled") return { state: "aborted", threadId, terminal: true }; + return { state: "completed", threadId, terminal: true }; + } + + if (method === "item/started") { + const state = mapItemType(params.item?.type, params.item); + return state ? { state, threadId, terminal: false } : null; + } + if (method === "item/completed") return { state: "think", threadId, terminal: false }; + if (method === "reasoning/text/delta" || method === "reasoning/summaryTextDelta") { + return { state: "reasoning", threadId, terminal: false }; + } + if ([ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/plan/delta", + ].includes(method)) { + return { state: "think", threadId, terminal: false }; + } + return null; +} + +function latestAppServerState({ appServerState, appServerFlags, appServerEvents, threadId }) { + if (appServerState) return { state: String(appServerState), threadId, terminal: false }; + const flagged = appServerFlagsToPetState(appServerFlags); + if (flagged) return { state: flagged, threadId, terminal: false }; + const events = Array.isArray(appServerEvents) ? appServerEvents : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const mapped = appServerEventToPetState(events[index], threadId); + if (mapped) return mapped; + } + return null; +} + +export function selectPetState({ + uiProbe, + knownThreadIds, + domActivity, + appServerState, + appServerFlags, + appServerEvents, + stateSource = "dom", +} = {}) { + const selected = resolveSelectedThread(uiProbe, knownThreadIds); + const policy = createStateSourcePolicy(stateSource); + if (!selected.threadId || !selected.running) { + return Object.freeze({ threadId: selected.threadId, state: "idle", source: "sidebar-gate" }); + } + + const domState = policy.allowDom ? domActivityToPetState(domActivity) : null; + // A visible approval/input card is a stronger UI fact than generic + // reasoning, so never hide it behind an app-server refinement. + if (domState === "waitingOnApproval" || domState === "waitingOnUserInput") { + return Object.freeze({ threadId: selected.threadId, state: domState, source: "dom" }); + } + + const server = policy.allowAppServer + ? latestAppServerState({ appServerState, appServerFlags, appServerEvents, threadId: selected.threadId }) + : null; + if (server) return Object.freeze({ threadId: selected.threadId, state: server.state, source: "app-server" }); + if (domState) return Object.freeze({ threadId: selected.threadId, state: domState, source: "dom" }); + return Object.freeze({ + threadId: selected.threadId, + state: "reasoning", + source: policy.requireAppServer ? "app-server-fallback" : "dom", + }); +} diff --git a/windows/scripts/pet-runtime.mjs b/windows/scripts/pet-runtime.mjs new file mode 100644 index 00000000..b8a335ce --- /dev/null +++ b/windows/scripts/pet-runtime.mjs @@ -0,0 +1,33 @@ +/** + * Generic pet state runtime. Animation assets and rendering remain the + * responsibility of the pet package; this module only validates and emits + * normalized states. + */ + +export function createPetRuntime({ stateMap = {}, initialState = "idle", onStateChange = () => {} } = {}) { + const hasState = (state) => Object.prototype.hasOwnProperty.call(stateMap, state); + let currentState = hasState(initialState) ? initialState : "idle"; + + const normalize = (state) => { + const candidate = String(state || "idle"); + return hasState(candidate) ? candidate : "idle"; + }; + + return Object.freeze({ + get state() { + return currentState; + }, + normalize, + setState(nextState) { + const next = normalize(nextState); + if (next === currentState) return false; + const previous = currentState; + currentState = next; + onStateChange(next, previous); + return true; + }, + reset() { + return this.setState("idle"); + }, + }); +} diff --git a/windows/scripts/pet-state-bridge.mjs b/windows/scripts/pet-state-bridge.mjs new file mode 100644 index 00000000..8cee61f5 --- /dev/null +++ b/windows/scripts/pet-state-bridge.mjs @@ -0,0 +1,36 @@ +import { selectPetState, normalizeStateSource } from "./codex-state-bridge.mjs"; +import { createPetRuntime } from "./pet-runtime.mjs"; + +/** + * Compose the DOM state contract with a pet runtime. The caller owns the + * adapter that collects snapshots from Codex; this object is safe to use from + * a CDP watcher, a native host, or a future extension process. + */ +export function createPetStateBridge({ stateMap = { idle: {} }, onStateChange, stateSource = "dom" } = {}) { + const runtime = createPetRuntime({ stateMap, onStateChange }); + const mode = normalizeStateSource(stateSource); + let lastSnapshot = { stateSource: mode }; + + const sync = (snapshot = {}) => { + lastSnapshot = { ...lastSnapshot, ...snapshot, stateSource: snapshot.stateSource || mode }; + const selected = selectPetState(lastSnapshot); + runtime.setState(selected.state); + return selected; + }; + + return Object.freeze({ + runtime, + stateSource: mode, + sync, + ingestAppServer(message) { + const events = Array.isArray(lastSnapshot.appServerEvents) + ? [...lastSnapshot.appServerEvents, message].slice(-64) + : [message]; + return sync({ appServerEvents: events }); + }, + reset() { + runtime.reset(); + return { state: runtime.state, source: "reset" }; + }, + }); +} diff --git a/windows/tests/pet-state-bridge.test.mjs b/windows/tests/pet-state-bridge.test.mjs new file mode 100644 index 00000000..1bbef007 --- /dev/null +++ b/windows/tests/pet-state-bridge.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { appServerEventToPetState, normalizeStateSource, selectPetState } from "../scripts/codex-state-bridge.mjs"; +import { createPetStateBridge } from "../scripts/pet-state-bridge.mjs"; + +const threadId = "019fd5ae-eb5b-7e01-bb4e-32b021aefd56"; +const runningProbe = { + ids: [threadId], + details: [{ id: threadId, running: true, title: "running task" }], +}; +const finishedProbe = { + ids: [threadId], + details: [{ id: threadId, running: false, title: "finished task" }], +}; + +const staleCompletion = selectPetState({ + uiProbe: finishedProbe, +}); +assert.deepEqual(staleCompletion, { + threadId, + state: "idle", + source: "sidebar-gate", +}); + +const domOnly = selectPetState({ + uiProbe: runningProbe, + domActivity: "waitingOnApproval", +}); +assert.deepEqual(domOnly, { + threadId, + state: "waitingOnApproval", + source: "dom", +}); + +const domModeIgnoresServerFlag = selectPetState({ + uiProbe: runningProbe, + domActivity: null, + appServerFlags: ["waitingOnApproval"], + stateSource: "dom", +}); +assert.deepEqual(domModeIgnoresServerFlag, { + threadId, + state: "reasoning", + source: "dom", +}); + +const enhancedFlag = selectPetState({ + uiProbe: runningProbe, + appServerFlags: ["commandExecution"], + stateSource: "dom-plus", +}); +assert.deepEqual(enhancedFlag, { + threadId, + state: "commandExecution", + source: "app-server", +}); + +const eventStates = [ + ["commandExecution", "commandExecution", { command: "Write-Output test" }], + ["fileRead", "commandExecution", { command: "Get-Content README.md" }], + ["fileChange", "fileChange", {}], + ["mcpToolCall", "mcpToolCall", {}], + ["webSearch", "webSearch", {}], + ["plan", "plan", {}], + ["reasoning", "reasoning", {}], +]; +for (const [expected, type, item] of eventStates) { + assert.equal(appServerEventToPetState({ method: "item/started", params: { + threadId, + item: { type, ...item }, + } }, threadId)?.state, expected); +} +assert.equal(appServerEventToPetState({ + method: "turn/completed", + params: { threadId, turn: { status: "completed" } }, +}, threadId)?.state, "completed"); +assert.equal(appServerEventToPetState({ + method: "turn/completed", + params: { threadId, turn: { status: "interrupted" } }, +}, threadId)?.state, "aborted"); +assert.equal(appServerEventToPetState({ + method: "item/started", + params: { threadId: "00000000-0000-0000-0000-000000000001", item: { type: "webSearch" } }, +}, threadId), null); + +const transitions = []; +const bridge = createPetStateBridge({ + stateMap: { idle: {}, reasoning: {}, waitingOnApproval: {} }, + onStateChange: (next, previous) => transitions.push([previous, next]), +}); +bridge.sync({ uiProbe: runningProbe, domActivity: "active" }); +bridge.sync({ uiProbe: finishedProbe }); +assert.equal(bridge.runtime.state, "idle"); +assert.deepEqual(transitions, [["idle", "reasoning"], ["reasoning", "idle"]]); + +const enhancedBridge = createPetStateBridge({ + stateSource: "dom-plus", + stateMap: { idle: {}, reasoning: {}, commandExecution: {} }, +}); +enhancedBridge.sync({ uiProbe: runningProbe }); +enhancedBridge.ingestAppServer({ + method: "item/started", + params: { threadId, item: { type: "commandExecution" } }, +}); +assert.equal(enhancedBridge.runtime.state, "commandExecution"); +assert.equal(normalizeStateSource("auto"), "dom-plus"); +assert.equal(normalizeStateSource("app-server"), "dom-plus"); +assert.equal(normalizeStateSource("dom"), "dom"); + +console.log("pet-state-bridge tests passed");