diff --git a/extensions/capabilities/index.ts b/extensions/capabilities/index.ts index 25835ecd..66e292dd 100644 --- a/extensions/capabilities/index.ts +++ b/extensions/capabilities/index.ts @@ -25,9 +25,12 @@ import { resetOpenPiToolSurface, } from "../shared/tool-surface.ts"; import { + CAPABILITY_SHIMMER_INTERVAL_MS, CapabilityIntentHighlightEditor, + capabilityShimmerPhase, colorCapabilityKeyword, isLightNamedTheme, + supportsDynamicCapabilityShimmer, } from "./src/ui.ts"; const CapabilitySchema = Type.Unsafe({ @@ -77,6 +80,7 @@ function skillGuidance(capabilities: readonly OpenPiCapability[]) { interface CapabilityExtensionDependencies { readonly loadConfig: () => Pick; readonly sourcePath?: string; + readonly supportsDynamicShimmer?: () => boolean; } export function createCapabilitiesExtension( @@ -85,6 +89,30 @@ export function createCapabilitiesExtension( }, ) { return function capabilities(pi: ExtensionAPI) { + let shimmerEditor: CapabilityIntentHighlightEditor | undefined; + let shimmerTimer: ReturnType | undefined; + let requestShimmerRender: (() => void) | undefined; + + const stopShimmer = () => { + if (shimmerTimer) clearInterval(shimmerTimer); + shimmerTimer = undefined; + shimmerEditor = undefined; + requestShimmerRender = undefined; + }; + + const startShimmer = ( + editor: CapabilityIntentHighlightEditor, + requestRender: () => void, + enabled: boolean, + ) => { + if (!enabled) return; + shimmerEditor = editor; + requestShimmerRender = requestRender; + shimmerTimer ??= setInterval(() => { + if (shimmerEditor?.hasCapabilityIntent()) requestShimmerRender?.(); + }, CAPABILITY_SHIMMER_INTERVAL_MS); + }; + const reconcileDiscoveryGateway = () => { const adaptive = dependencies.loadConfig().capabilities.discovery === "adaptive"; @@ -98,6 +126,10 @@ export function createCapabilitiesExtension( pi.events.on(SETUP_CONFIG_CHANGED_CHANNEL, reconcileDiscoveryGateway); pi.on("session_start", (_event, ctx) => { + stopShimmer(); + const animateShimmer = + dependencies.supportsDynamicShimmer?.() ?? + supportsDynamicCapabilityShimmer(); resetOpenPiToolSurface( pi, dependencies.sourcePath @@ -108,17 +140,26 @@ export function createCapabilitiesExtension( registerEditorLayer(pi, ctx, { id: "capability-intent-highlight", order: 150, - wrap: (base, _tui, _theme, keybindings) => - new CapabilityIntentHighlightEditor(base, keybindings, (text) => - colorCapabilityKeyword(text, { - colorMode: ctx.ui.theme.getColorMode(), - light: isLightNamedTheme(ctx.ui.theme.name), - }), - ), + wrap: (base, tui, _theme, keybindings) => { + const editor = new CapabilityIntentHighlightEditor( + base, + keybindings, + (text) => + colorCapabilityKeyword(text, { + colorMode: ctx.ui.theme.getColorMode(), + light: isLightNamedTheme(ctx.ui.theme.name), + animated: animateShimmer, + ...(animateShimmer ? { phase: capabilityShimmerPhase() } : {}), + }), + ); + startShimmer(editor, () => tui.requestRender(), animateShimmer); + return editor; + }, }); }); pi.on("session_shutdown", () => { + stopShimmer(); removeEditorLayer(pi, "capability-intent-highlight"); }); diff --git a/extensions/capabilities/src/ui.ts b/extensions/capabilities/src/ui.ts index e8a7921f..309a99c9 100644 --- a/extensions/capabilities/src/ui.ts +++ b/extensions/capabilities/src/ui.ts @@ -1,5 +1,5 @@ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent"; -import type { EditorComponent } from "@earendil-works/pi-tui"; +import { getCapabilities, type EditorComponent } from "@earendil-works/pi-tui"; import { BelowEditorNavigationEditor, BelowEditorStripState, @@ -9,32 +9,127 @@ import { capabilitiesRequestedByPrompt } from "../../shared/capability-intent.ts const DELEGATE_NAMES = /\bsubagents?\b|子代理/giu; const WORKFLOW_NAMES = /\bworkflows?\b|工作流/giu; const FOREGROUND_RESET = "\u001b[39m"; +const SHIMMER_PERIOD_MS = 1_400; + +type Rgb = readonly [number, number, number]; + +const DARK_SHIMMER_BASE: Rgb = [177, 119, 233]; +const DARK_SHIMMER_HIGHLIGHT: Rgb = [255, 232, 255]; +const LIGHT_SHIMMER_BASE: Rgb = [91, 48, 173]; +const LIGHT_SHIMMER_HIGHLIGHT: Rgb = [205, 171, 255]; +const DARK_STATIC_PURPLE: Rgb = [210, 168, 255]; +const LIGHT_STATIC_PURPLE: Rgb = [130, 80, 223]; + +/** Keep editor animation cadence aligned with the package's other TUI motion. */ +export const CAPABILITY_SHIMMER_INTERVAL_MS = 120; interface CapabilityKeywordColorOptions { readonly colorMode: "truecolor" | "256color"; readonly light: boolean; + readonly animated?: boolean; + /** A normalized position in the shimmer cycle. Defaults to the first frame. */ + readonly phase?: number; } -export function isLightNamedTheme(name: string | undefined) { - return name !== undefined && /(?:^|[-_])light(?:$|[-_])/iu.test(name); +interface CapabilityShimmerTerminal { + readonly isTTY?: boolean; + readonly term?: string; + readonly trueColor: boolean; } /** - * Claude-style lavender keyword color. The light variant preserves readable - * contrast instead of mechanically reusing the bright dark-terminal swatch. + * Dynamic color updates need an interactive terminal with a known color + * capability. A 256-color terminal remains eligible; color mode alone is not + * used as the animation decision. */ +export function supportsDynamicCapabilityShimmer( + terminal: CapabilityShimmerTerminal = { + isTTY: process.stdout.isTTY, + term: process.env.TERM, + trueColor: getCapabilities().trueColor, + }, +) { + const term = terminal.term?.toLowerCase(); + if (terminal.isTTY === false || term === "dumb") return false; + return terminal.trueColor || term?.includes("256color") === true; +} + +export function isLightNamedTheme(name: string | undefined) { + return name !== undefined && /(?:^|[-_])light(?:$|[-_])/iu.test(name); +} + +function mix(a: number, b: number, amount: number) { + return Math.round(a + (b - a) * amount); +} + +function shimmerIntensity(index: number, length: number, phase: number) { + const position = length <= 1 ? 0.5 : index / (length - 1); + const distance = Math.abs(position - phase); + const wrappedDistance = Math.min(distance, 1 - distance); + const glow = Math.max(0, 1 - wrappedDistance / 0.35); + return 0.2 + 0.8 * glow * glow; +} + +function shimmerRgb(base: Rgb, highlight: Rgb, amount: number): Rgb { + return [ + mix(base[0], highlight[0], amount), + mix(base[1], highlight[1], amount), + mix(base[2], highlight[2], amount), + ]; +} + +function truecolorForeground([red, green, blue]: Rgb) { + return `\u001b[38;2;${red};${green};${blue}m`; +} + +function ansi256Foreground(light: boolean, intensity: number) { + if (light) { + return `\u001b[38;5;${intensity > 0.8 ? 147 : intensity > 0.5 ? 141 : 98}m`; + } + return `\u001b[38;5;${intensity > 0.8 ? 225 : intensity > 0.5 ? 189 : 183}m`; +} + +function staticPurpleForeground( + light: boolean, + colorMode: CapabilityKeywordColorOptions["colorMode"], +) { + return colorMode === "truecolor" + ? truecolorForeground(light ? LIGHT_STATIC_PURPLE : DARK_STATIC_PURPLE) + : ansi256Foreground(light, 0.2); +} + +/** Claude-style purple shimmer. The light variant preserves readable contrast. */ export function colorCapabilityKeyword( text: string, options: CapabilityKeywordColorOptions, ) { - const start = options.light - ? options.colorMode === "truecolor" - ? "\u001b[38;2;130;80;223m" - : "\u001b[38;5;98m" - : options.colorMode === "truecolor" - ? "\u001b[38;2;210;168;255m" - : "\u001b[38;5;183m"; - return `${start}${text}${FOREGROUND_RESET}`; + if (options.animated === false) { + return `${staticPurpleForeground(options.light, options.colorMode)}${text}${FOREGROUND_RESET}`; + } + const phase = (((options.phase ?? 0) % 1) + 1) % 1; + const characters = [...text]; + const base = options.light ? LIGHT_SHIMMER_BASE : DARK_SHIMMER_BASE; + const highlight = options.light + ? LIGHT_SHIMMER_HIGHLIGHT + : DARK_SHIMMER_HIGHLIGHT; + + return `${characters + .map((character, index) => { + const intensity = shimmerIntensity(index, characters.length, phase); + const start = + options.colorMode === "truecolor" + ? truecolorForeground(shimmerRgb(base, highlight, intensity)) + : ansi256Foreground(options.light, intensity); + return `${start}${character}`; + }) + .join("")}${FOREGROUND_RESET}`; +} + +export function capabilityShimmerPhase(now = Date.now()) { + return ( + (((now % SHIMMER_PERIOD_MS) + SHIMMER_PERIOD_MS) % SHIMMER_PERIOD_MS) / + SHIMMER_PERIOD_MS + ); } export function highlightCapabilityNames( @@ -76,6 +171,13 @@ export class CapabilityIntentHighlightEditor extends BelowEditorNavigationEditor this.highlight = highlight; } + hasCapabilityIntent() { + const capabilities = capabilitiesRequestedByPrompt(this.getText()); + return ( + capabilities.includes("delegate") || capabilities.includes("workflow") + ); + } + override render(width: number) { const capabilities = capabilitiesRequestedByPrompt(this.getText()); if ( diff --git a/tests/extensions/capabilities/index.test.ts b/tests/extensions/capabilities/index.test.ts index c23d662c..7efa7774 100644 --- a/tests/extensions/capabilities/index.test.ts +++ b/tests/extensions/capabilities/index.test.ts @@ -3,7 +3,9 @@ import test from "node:test"; import type { ExtensionAPI, ExtensionContext, + KeybindingsManager, } from "@earendil-works/pi-coding-agent"; +import type { EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui"; import { SETUP_CONFIG_CHANGED_CHANNEL } from "../../../extensions/shared/setup-config.ts"; import { OPENPI_TOOL_SURFACE, @@ -390,3 +392,89 @@ test("capability loads are monotonic and activate only the requested group", asy assert.deepEqual(second.details.activatedTools, []); assert.deepEqual(second.details.loaded, ["search"]); }); + +test("static shimmer fallback does not schedule animation redraws", () => { + type RegisteredLayer = { + wrap: ( + base: EditorComponent, + tui: TUI, + theme: EditorTheme, + keybindings: KeybindingsManager, + ) => unknown; + }; + + let sessionStart: + | ((event: unknown, ctx: ExtensionContext) => void) + | undefined; + const registrations: RegisteredLayer[] = []; + const eventHandlers = new Map void>>(); + let active = ["read", "bash", "edit", "write"]; + const pi = { + events: { + on(channel: string, handler: (data: unknown) => void) { + const handlers = eventHandlers.get(channel) ?? new Set(); + handlers.add(handler); + eventHandlers.set(channel, handlers); + return () => handlers.delete(handler); + }, + emit(channel: string, data: unknown) { + if (channel.endsWith(":register")) { + const layer = (data as { layer?: RegisteredLayer }).layer; + if (layer) registrations.push(layer); + } + for (const handler of eventHandlers.get(channel) ?? []) handler(data); + }, + }, + on(event: string, handler: unknown) { + if (event === "session_start") { + sessionStart = handler as typeof sessionStart; + } + }, + registerTool() {}, + getActiveTools: () => [...active], + getAllTools: () => active.map((name) => ({ name })), + setActiveTools(names: string[]) { + active = [...names]; + }, + }; + + createCapabilitiesExtension({ + loadConfig: () => ({ capabilities: { discovery: "explicit" } }), + supportsDynamicShimmer: () => false, + })(pi as unknown as ExtensionAPI); + + assert.ok(sessionStart); + let intervalCalls = 0; + const originalSetInterval = globalThis.setInterval; + globalThis.setInterval = ((..._args: Parameters) => { + intervalCalls++; + return {} as ReturnType; + }) as typeof setInterval; + + try { + const ctx = { + mode: "tui", + ui: { + theme: { + getColorMode: () => "truecolor", + name: "dark", + }, + getEditorComponent: () => undefined, + setEditorComponent() {}, + }, + } as unknown as ExtensionContext; + sessionStart({}, ctx); + + assert.equal(registrations.length, 1); + registrations[0]!.wrap( + {} as EditorComponent, + { requestRender() {} } as TUI, + {} as EditorTheme, + {} as KeybindingsManager, + ); + } finally { + globalThis.setInterval = originalSetInterval; + } + + assert.equal(intervalCalls, 0); +}); diff --git a/tests/extensions/capabilities/ui.test.ts b/tests/extensions/capabilities/ui.test.ts index 8cc3b24a..d6d1788a 100644 --- a/tests/extensions/capabilities/ui.test.ts +++ b/tests/extensions/capabilities/ui.test.ts @@ -9,11 +9,17 @@ import { } from "../../../extensions/suggestions/src/ui.ts"; import { CapabilityIntentHighlightEditor, + capabilityShimmerPhase, colorCapabilityKeyword, highlightCapabilityNames, isLightNamedTheme, + supportsDynamicCapabilityShimmer, } from "../../../extensions/capabilities/src/ui.ts"; +function stripSgr(value: string) { + return value.replace(/\u001b\[[0-9;]*m/gu, ""); +} + function baseEditor(initial: string): EditorComponent { let text = initial; return { @@ -39,39 +45,84 @@ function editor(initial: string) { }; } -test("uses Claude-style lavender with a contrast-safe light variant", () => { +test("renders a moving purple shimmer with a contrast-safe light variant", () => { assert.equal(isLightNamedTheme("light"), true); assert.equal(isLightNamedTheme("github-light-default"), true); assert.equal(isLightNamedTheme("dark"), false); assert.equal(isLightNamedTheme(undefined), false); + const darkFrame = colorCapabilityKeyword("subagent", { + colorMode: "truecolor", + light: false, + phase: 0, + }); + const darkNextFrame = colorCapabilityKeyword("subagent", { + colorMode: "truecolor", + light: false, + phase: 0.5, + }); + const lightFrame = colorCapabilityKeyword("workflow", { + colorMode: "256color", + light: true, + phase: 0, + }); + + assert.notEqual(darkFrame, darkNextFrame); + assert.match(darkFrame, /\u001b\[38;2;[0-9;]+m/gu); + assert.match(lightFrame, /\u001b\[38;5;[0-9]+m/gu); + assert.match(darkFrame, /\u001b\[39m$/u); + assert.equal(stripSgr(darkFrame), "subagent"); + assert.equal(stripSgr(darkNextFrame), "subagent"); + assert.equal(stripSgr(lightFrame), "workflow"); + + assert.equal(capabilityShimmerPhase(0), 0); + assert.equal(capabilityShimmerPhase(1_400), 0); + assert.equal(capabilityShimmerPhase(700), 0.5); +}); + +test("static purple fallback ignores shimmer phase", () => { + const staticFrame = colorCapabilityKeyword("subagent", { + animated: false, + colorMode: "truecolor", + light: false, + phase: 0, + }); + const staticNextFrame = colorCapabilityKeyword("subagent", { + animated: false, + colorMode: "truecolor", + light: false, + phase: 0.5, + }); + + assert.equal(staticFrame, staticNextFrame); + assert.match(staticFrame, /\u001b\[38;2;210;168;255m/gu); + assert.equal(stripSgr(staticFrame), "subagent"); +}); + +test("dynamic shimmer support is independent of selected color mode", () => { assert.equal( - colorCapabilityKeyword("subagent", { - colorMode: "truecolor", - light: false, - }), - "\u001b[38;2;210;168;255msubagent\u001b[39m", - ); - assert.equal( - colorCapabilityKeyword("workflow", { - colorMode: "256color", - light: false, + supportsDynamicCapabilityShimmer({ + isTTY: true, + term: "xterm-256color", + trueColor: false, }), - "\u001b[38;5;183mworkflow\u001b[39m", + true, ); assert.equal( - colorCapabilityKeyword("subagent", { - colorMode: "truecolor", - light: true, + supportsDynamicCapabilityShimmer({ + isTTY: true, + term: "dumb", + trueColor: true, }), - "\u001b[38;2;130;80;223msubagent\u001b[39m", + false, ); assert.equal( - colorCapabilityKeyword("workflow", { - colorMode: "256color", - light: true, + supportsDynamicCapabilityShimmer({ + isTTY: false, + term: "xterm-256color", + trueColor: true, }), - "\u001b[38;5;98mworkflow\u001b[39m", + false, ); });