Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions extensions/capabilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenPiCapability>({
Expand Down Expand Up @@ -77,6 +80,7 @@ function skillGuidance(capabilities: readonly OpenPiCapability[]) {
interface CapabilityExtensionDependencies {
readonly loadConfig: () => Pick<MyPiSetupConfig, "capabilities">;
readonly sourcePath?: string;
readonly supportsDynamicShimmer?: () => boolean;
}

export function createCapabilitiesExtension(
Expand All @@ -85,6 +89,30 @@ export function createCapabilitiesExtension(
},
) {
return function capabilities(pi: ExtensionAPI) {
let shimmerEditor: CapabilityIntentHighlightEditor | undefined;
let shimmerTimer: ReturnType<typeof setInterval> | 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";
Expand All @@ -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
Expand All @@ -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");
});

Expand Down
128 changes: 115 additions & 13 deletions extensions/capabilities/src/ui.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [P2] 补齐 Issue #301 要求的静态紫色降级路径

#301 明确要求“不支持动态效果的终端能够正常显示静态紫色”。这里的 truecolor 与 256-color 分支都使用随 phase 变化的 intensity;index.ts 也无条件传入动态 phase,并在存在关键词时每 120ms 请求刷新,因此目前没有实现该静态降级行为。实际调用探针也确认了四个关键词在两种色彩模式下 phase=0 与 0.5 的输出均不同。

这不是说 256-color 终端一定不能动画,而是颜色模式切换本身不能实现约定的静态降级。请用最小改动保留静态紫色路径,并让进入降级路径时不再触发动画刷新;补充该路径输出不随 phase 变化、不会继续动画重绘的测试即可,不需要额外配置或动画框架。

: 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(
Expand Down Expand Up @@ -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 (
Expand Down
88 changes: 88 additions & 0 deletions tests/extensions/capabilities/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, Set<(data: unknown) => 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<typeof setInterval>) => {
intervalCalls++;
return {} as ReturnType<typeof setInterval>;
}) 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);
});
Loading
Loading