mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 05:03:26 +00:00
feat(coding-agent): ResourceLoader, package management, and /reload command (#645)
- Add ResourceLoader interface and DefaultResourceLoader implementation - Add PackageManager for npm/git extension sources with install/remove/update - Add session.reload() and session.bindExtensions() APIs - Add /reload command in interactive mode - Add CLI flags: --skill, --theme, --prompt-template, --no-themes, --no-prompt-templates - Add pi install/remove/update commands for extension management - Refactor settings.json to use arrays for skills, prompts, themes - Remove legacy SkillsSettings source flags and filters - Update SDK examples and documentation for ResourceLoader pattern - Add theme registration and loadThemeFromPath for dynamic themes - Add getShellEnv to include bin dir in PATH for bash commands
This commit is contained in:
parent
866d21c252
commit
b846a4bfcf
51 changed files with 2724 additions and 1852 deletions
|
|
@ -1,42 +1,66 @@
|
|||
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import type { Theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { keyHint } from "./keybinding-hints.js";
|
||||
|
||||
/** Loader wrapped with borders for extension UI */
|
||||
export class BorderedLoader extends Container {
|
||||
private loader: CancellableLoader;
|
||||
private loader: CancellableLoader | Loader;
|
||||
private cancellable: boolean;
|
||||
private signalController?: AbortController;
|
||||
|
||||
constructor(tui: TUI, theme: Theme, message: string) {
|
||||
constructor(tui: TUI, theme: Theme, message: string, options?: { cancellable?: boolean }) {
|
||||
super();
|
||||
this.cancellable = options?.cancellable ?? true;
|
||||
const borderColor = (s: string) => theme.fg("border", s);
|
||||
this.addChild(new DynamicBorder(borderColor));
|
||||
this.loader = new CancellableLoader(
|
||||
tui,
|
||||
(s) => theme.fg("accent", s),
|
||||
(s) => theme.fg("muted", s),
|
||||
message,
|
||||
);
|
||||
if (this.cancellable) {
|
||||
this.loader = new CancellableLoader(
|
||||
tui,
|
||||
(s) => theme.fg("accent", s),
|
||||
(s) => theme.fg("muted", s),
|
||||
message,
|
||||
);
|
||||
} else {
|
||||
this.signalController = new AbortController();
|
||||
this.loader = new Loader(
|
||||
tui,
|
||||
(s) => theme.fg("accent", s),
|
||||
(s) => theme.fg("muted", s),
|
||||
message,
|
||||
);
|
||||
}
|
||||
this.addChild(this.loader);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
|
||||
if (this.cancellable) {
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
|
||||
}
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder(borderColor));
|
||||
}
|
||||
|
||||
get signal(): AbortSignal {
|
||||
return this.loader.signal;
|
||||
if (this.cancellable) {
|
||||
return (this.loader as CancellableLoader).signal;
|
||||
}
|
||||
return this.signalController?.signal ?? new AbortController().signal;
|
||||
}
|
||||
|
||||
set onAbort(fn: (() => void) | undefined) {
|
||||
this.loader.onAbort = fn;
|
||||
if (this.cancellable) {
|
||||
(this.loader as CancellableLoader).onAbort = fn;
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.loader.handleInput(data);
|
||||
if (this.cancellable) {
|
||||
(this.loader as CancellableLoader).handleInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.loader.dispose();
|
||||
if ("dispose" in this.loader && typeof this.loader.dispose === "function") {
|
||||
this.loader.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ import { type AppAction, KeybindingsManager } from "../../core/keybindings.js";
|
|||
import { createCompactionSummaryMessage } from "../../core/messages.js";
|
||||
import { resolveModelScope } from "../../core/model-resolver.js";
|
||||
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
|
||||
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.js";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
|
||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||
|
|
@ -156,7 +155,6 @@ export class InteractiveMode {
|
|||
private keybindings: KeybindingsManager;
|
||||
private version: string;
|
||||
private isInitialized = false;
|
||||
private hasRenderedInitialMessages = false;
|
||||
private onInputCallback?: (text: string) => void;
|
||||
private loadingAnimation: Loader | undefined = undefined;
|
||||
private readonly defaultWorkingMessage = "Working...";
|
||||
|
|
@ -321,6 +319,7 @@ export class InteractiveMode {
|
|||
{ name: "new", description: "Start a new session" },
|
||||
{ name: "compact", description: "Manually compact the session context" },
|
||||
{ name: "resume", description: "Resume a different session" },
|
||||
{ name: "reload", description: "Reload extensions, skills, prompts, and themes" },
|
||||
];
|
||||
|
||||
// Convert prompt templates to SlashCommand format for autocomplete
|
||||
|
|
@ -617,133 +616,63 @@ export class InteractiveMode {
|
|||
// Extension System
|
||||
// =========================================================================
|
||||
|
||||
private showLoadedResources(options?: { extensionPaths?: string[]; force?: boolean }): void {
|
||||
const shouldShow = options?.force || !this.settingsManager.getQuietStartup();
|
||||
if (!shouldShow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
|
||||
if (contextFiles.length > 0) {
|
||||
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
const skills = this.session.skills;
|
||||
if (skills.length > 0) {
|
||||
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
const skillWarnings = this.session.skillWarnings;
|
||||
if (skillWarnings.length > 0) {
|
||||
const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
const templates = this.session.promptTemplates;
|
||||
if (templates.length > 0) {
|
||||
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
const extensionPaths = options?.extensionPaths ?? [];
|
||||
if (extensionPaths.length > 0) {
|
||||
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the extension system with TUI-based UI context.
|
||||
*/
|
||||
private async initExtensions(): Promise<void> {
|
||||
// Show discovery info unless silenced
|
||||
if (!this.settingsManager.getQuietStartup()) {
|
||||
// Show loaded project context files
|
||||
const contextFiles = loadProjectContextFiles();
|
||||
if (contextFiles.length > 0) {
|
||||
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show loaded skills (already discovered by SDK)
|
||||
const skills = this.session.skills;
|
||||
if (skills.length > 0) {
|
||||
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show skill warnings if any
|
||||
const skillWarnings = this.session.skillWarnings;
|
||||
if (skillWarnings.length > 0) {
|
||||
const warningList = skillWarnings
|
||||
.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`))
|
||||
.join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show loaded prompt templates
|
||||
const templates = this.session.promptTemplates;
|
||||
if (templates.length > 0) {
|
||||
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (!extensionRunner) {
|
||||
return; // No extensions loaded
|
||||
this.showLoadedResources({ extensionPaths: [], force: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create extension UI context
|
||||
const uiContext = this.createExtensionUIContext();
|
||||
|
||||
extensionRunner.initialize(
|
||||
// ExtensionActions - for pi.* API
|
||||
{
|
||||
sendMessage: (message, options) => {
|
||||
const wasStreaming = this.session.isStreaming;
|
||||
this.session
|
||||
.sendCustomMessage(message, options)
|
||||
.then(() => {
|
||||
// Don't rebuild if initial render hasn't happened yet
|
||||
// (renderInitialMessages will handle it)
|
||||
if (!wasStreaming && message.display && this.hasRenderedInitialMessages) {
|
||||
this.rebuildChatFromMessages();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this.showError(
|
||||
`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
sendUserMessage: (content, options) => {
|
||||
this.session.sendUserMessage(content, options).catch((err) => {
|
||||
this.showError(
|
||||
`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
appendEntry: (customType, data) => {
|
||||
this.sessionManager.appendCustomEntry(customType, data);
|
||||
},
|
||||
setSessionName: (name) => {
|
||||
this.sessionManager.appendSessionInfo(name);
|
||||
this.updateTerminalTitle();
|
||||
},
|
||||
getSessionName: () => {
|
||||
return this.sessionManager.getSessionName();
|
||||
},
|
||||
setLabel: (entryId, label) => {
|
||||
this.sessionManager.appendLabelChange(entryId, label);
|
||||
},
|
||||
getActiveTools: () => this.session.getActiveToolNames(),
|
||||
getAllTools: () => this.session.getAllTools(),
|
||||
setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
|
||||
setModel: async (model) => {
|
||||
const key = await this.session.modelRegistry.getApiKey(model);
|
||||
if (!key) return false;
|
||||
await this.session.setModel(model);
|
||||
return true;
|
||||
},
|
||||
getThinkingLevel: () => this.session.thinkingLevel,
|
||||
setThinkingLevel: (level) => this.session.setThinkingLevel(level),
|
||||
},
|
||||
// ExtensionContextActions - for ctx.* in event handlers
|
||||
{
|
||||
getModel: () => this.session.model,
|
||||
isIdle: () => !this.session.isStreaming,
|
||||
abort: () => this.session.abort(),
|
||||
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
||||
shutdown: () => {
|
||||
this.shutdownRequested = true;
|
||||
},
|
||||
getContextUsage: () => this.session.getContextUsage(),
|
||||
compact: (options) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await this.executeCompaction(options?.customInstructions, false);
|
||||
if (result) {
|
||||
options?.onComplete?.(result);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
options?.onError?.(err);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
// ExtensionCommandContextActions - for ctx.* in command handlers
|
||||
{
|
||||
await this.session.bindExtensions({
|
||||
uiContext,
|
||||
commandContextActions: {
|
||||
waitForIdle: () => this.session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
if (this.loadingAnimation) {
|
||||
|
|
@ -808,31 +737,16 @@ export class InteractiveMode {
|
|||
return { cancelled: false };
|
||||
},
|
||||
},
|
||||
uiContext,
|
||||
);
|
||||
|
||||
// Subscribe to extension errors
|
||||
extensionRunner.onError((error) => {
|
||||
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
||||
shutdownHandler: () => {
|
||||
this.shutdownRequested = true;
|
||||
},
|
||||
onError: (error) => {
|
||||
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
||||
},
|
||||
});
|
||||
|
||||
// Set up extension-registered shortcuts
|
||||
this.setupExtensionShortcuts(extensionRunner);
|
||||
|
||||
// Show loaded extensions (unless silenced)
|
||||
if (!this.settingsManager.getQuietStartup()) {
|
||||
const extensionPaths = extensionRunner.getExtensionPaths();
|
||||
if (extensionPaths.length > 0) {
|
||||
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
|
||||
// Emit session_start event
|
||||
await extensionRunner.emit({
|
||||
type: "session_start",
|
||||
});
|
||||
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -950,6 +864,38 @@ export class InteractiveMode {
|
|||
this.renderWidgets();
|
||||
}
|
||||
|
||||
private clearExtensionWidgets(): void {
|
||||
for (const widget of this.extensionWidgetsAbove.values()) {
|
||||
widget.dispose?.();
|
||||
}
|
||||
for (const widget of this.extensionWidgetsBelow.values()) {
|
||||
widget.dispose?.();
|
||||
}
|
||||
this.extensionWidgetsAbove.clear();
|
||||
this.extensionWidgetsBelow.clear();
|
||||
this.renderWidgets();
|
||||
}
|
||||
|
||||
private resetExtensionUI(): void {
|
||||
if (this.extensionSelector) {
|
||||
this.hideExtensionSelector();
|
||||
}
|
||||
if (this.extensionInput) {
|
||||
this.hideExtensionInput();
|
||||
}
|
||||
if (this.extensionEditor) {
|
||||
this.hideExtensionEditor();
|
||||
}
|
||||
this.ui.hideOverlay();
|
||||
this.setExtensionFooter(undefined);
|
||||
this.setExtensionHeader(undefined);
|
||||
this.clearExtensionWidgets();
|
||||
this.footerDataProvider.clearExtensionStatuses();
|
||||
this.footer.invalidate();
|
||||
this.setCustomEditorComponent(undefined);
|
||||
this.defaultEditor.onExtensionShortcut = undefined;
|
||||
}
|
||||
|
||||
// Maximum total widget lines to prevent viewport overflow
|
||||
private static readonly MAX_WIDGET_LINES = 10;
|
||||
|
||||
|
|
@ -1608,6 +1554,11 @@ export class InteractiveMode {
|
|||
await this.handleCompactCommand(customInstructions);
|
||||
return;
|
||||
}
|
||||
if (text === "/reload") {
|
||||
this.editor.setText("");
|
||||
await this.handleReloadCommand();
|
||||
return;
|
||||
}
|
||||
if (text === "/debug") {
|
||||
this.handleDebugCommand();
|
||||
this.editor.setText("");
|
||||
|
|
@ -2143,7 +2094,6 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
renderInitialMessages(): void {
|
||||
this.hasRenderedInitialMessages = true;
|
||||
// Get aligned messages and entries from session context
|
||||
const context = this.sessionManager.buildSessionContext();
|
||||
this.renderSessionContext(context, {
|
||||
|
|
@ -3276,6 +3226,53 @@ export class InteractiveMode {
|
|||
// Command handlers
|
||||
// =========================================================================
|
||||
|
||||
private async handleReloadCommand(): Promise<void> {
|
||||
if (this.session.isStreaming) {
|
||||
this.showWarning("Wait for the current response to finish before reloading.");
|
||||
return;
|
||||
}
|
||||
if (this.session.isCompacting) {
|
||||
this.showWarning("Wait for compaction to finish before reloading.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetExtensionUI();
|
||||
|
||||
const loader = new BorderedLoader(this.ui, theme, "Reloading resources...", { cancellable: false });
|
||||
const previousEditor = this.editor;
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(loader);
|
||||
this.ui.setFocus(loader);
|
||||
this.ui.requestRender();
|
||||
|
||||
const restoreEditor = () => {
|
||||
loader.dispose();
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(previousEditor);
|
||||
this.ui.setFocus(previousEditor as Component);
|
||||
this.ui.requestRender();
|
||||
};
|
||||
|
||||
try {
|
||||
await this.session.reload();
|
||||
this.rebuildAutocomplete();
|
||||
const runner = this.session.extensionRunner;
|
||||
if (runner) {
|
||||
this.setupExtensionShortcuts(runner);
|
||||
}
|
||||
restoreEditor();
|
||||
this.showLoadedResources({ extensionPaths: runner?.getExtensionPaths() ?? [], force: true });
|
||||
const modelsJsonError = this.session.modelRegistry.getError();
|
||||
if (modelsJsonError) {
|
||||
this.showError(`models.json error: ${modelsJsonError}`);
|
||||
}
|
||||
this.showStatus("Reloaded resources");
|
||||
} catch (error) {
|
||||
restoreEditor();
|
||||
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleExportCommand(text: string): Promise<void> {
|
||||
const parts = text.split(/\s+/);
|
||||
const outputPath = parts.length > 1 ? parts[1] : undefined;
|
||||
|
|
@ -3632,12 +3629,13 @@ export class InteractiveMode {
|
|||
|
||||
private handleDebugCommand(): void {
|
||||
const width = this.ui.terminal.columns;
|
||||
const height = this.ui.terminal.rows;
|
||||
const allLines = this.ui.render(width);
|
||||
|
||||
const debugLogPath = getDebugLogPath();
|
||||
const debugData = [
|
||||
`Debug output at ${new Date().toISOString()}`,
|
||||
`Terminal width: ${width}`,
|
||||
`Terminal: ${width}x${height}`,
|
||||
`Total lines: ${allLines.length}`,
|
||||
"",
|
||||
"=== All rendered lines with visible widths ===",
|
||||
|
|
|
|||
|
|
@ -334,6 +334,8 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
|
|||
// ============================================================================
|
||||
|
||||
export class Theme {
|
||||
readonly name?: string;
|
||||
readonly sourcePath?: string;
|
||||
private fgColors: Map<ThemeColor, string>;
|
||||
private bgColors: Map<ThemeBg, string>;
|
||||
private mode: ColorMode;
|
||||
|
|
@ -342,7 +344,10 @@ export class Theme {
|
|||
fgColors: Record<ThemeColor, string | number>,
|
||||
bgColors: Record<ThemeBg, string | number>,
|
||||
mode: ColorMode,
|
||||
options: { name?: string; sourcePath?: string } = {},
|
||||
) {
|
||||
this.name = options.name;
|
||||
this.sourcePath = options.sourcePath;
|
||||
this.mode = mode;
|
||||
this.fgColors = new Map();
|
||||
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
|
||||
|
|
@ -457,6 +462,9 @@ export function getAvailableThemes(): string[] {
|
|||
}
|
||||
}
|
||||
}
|
||||
for (const name of registeredThemes.keys()) {
|
||||
themes.add(name);
|
||||
}
|
||||
return Array.from(themes).sort();
|
||||
}
|
||||
|
||||
|
|
@ -487,26 +495,16 @@ export function getAvailableThemesWithPaths(): ThemeInfo[] {
|
|||
}
|
||||
}
|
||||
|
||||
for (const [name, theme] of registeredThemes.entries()) {
|
||||
if (!result.some((t) => t.name === name)) {
|
||||
result.push({ name, path: theme.sourcePath });
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function loadThemeJson(name: string): ThemeJson {
|
||||
const builtinThemes = getBuiltinThemes();
|
||||
if (name in builtinThemes) {
|
||||
return builtinThemes[name];
|
||||
}
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
const themePath = path.join(customThemesDir, `${name}.json`);
|
||||
if (!fs.existsSync(themePath)) {
|
||||
throw new Error(`Theme not found: ${name}`);
|
||||
}
|
||||
const content = fs.readFileSync(themePath, "utf-8");
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse theme ${name}: ${error}`);
|
||||
}
|
||||
function parseThemeJson(label: string, json: unknown): ThemeJson {
|
||||
if (!validateThemeJson.Check(json)) {
|
||||
const errors = Array.from(validateThemeJson.Errors(json));
|
||||
const missingColors: string[] = [];
|
||||
|
|
@ -522,12 +520,12 @@ function loadThemeJson(name: string): ThemeJson {
|
|||
}
|
||||
}
|
||||
|
||||
let errorMessage = `Invalid theme "${name}":\n`;
|
||||
let errorMessage = `Invalid theme "${label}":\n`;
|
||||
if (missingColors.length > 0) {
|
||||
errorMessage += `\nMissing required color tokens:\n`;
|
||||
errorMessage += "\nMissing required color tokens:\n";
|
||||
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
|
||||
errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
|
||||
errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
|
||||
errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.';
|
||||
errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values.";
|
||||
}
|
||||
if (otherErrors.length > 0) {
|
||||
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
|
||||
|
|
@ -535,10 +533,35 @@ function loadThemeJson(name: string): ThemeJson {
|
|||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return json as ThemeJson;
|
||||
}
|
||||
|
||||
function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
|
||||
function parseThemeJsonContent(label: string, content: string): ThemeJson {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse theme ${label}: ${error}`);
|
||||
}
|
||||
return parseThemeJson(label, json);
|
||||
}
|
||||
|
||||
function loadThemeJson(name: string): ThemeJson {
|
||||
const builtinThemes = getBuiltinThemes();
|
||||
if (name in builtinThemes) {
|
||||
return builtinThemes[name];
|
||||
}
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
const themePath = path.join(customThemesDir, `${name}.json`);
|
||||
if (!fs.existsSync(themePath)) {
|
||||
throw new Error(`Theme not found: ${name}`);
|
||||
}
|
||||
const content = fs.readFileSync(themePath, "utf-8");
|
||||
return parseThemeJsonContent(name, content);
|
||||
}
|
||||
|
||||
function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme {
|
||||
const colorMode = mode ?? detectColorMode();
|
||||
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
||||
|
|
@ -558,10 +581,23 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
|
|||
fgColors[key as ThemeColor] = value;
|
||||
}
|
||||
}
|
||||
return new Theme(fgColors, bgColors, colorMode);
|
||||
return new Theme(fgColors, bgColors, colorMode, {
|
||||
name: themeJson.name,
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme {
|
||||
const content = fs.readFileSync(themePath, "utf-8");
|
||||
const themeJson = parseThemeJsonContent(themePath, content);
|
||||
return createTheme(themeJson, mode, themePath);
|
||||
}
|
||||
|
||||
function loadTheme(name: string, mode?: ColorMode): Theme {
|
||||
const registeredTheme = registeredThemes.get(name);
|
||||
if (registeredTheme) {
|
||||
return registeredTheme;
|
||||
}
|
||||
const themeJson = loadThemeJson(name);
|
||||
return createTheme(themeJson, mode);
|
||||
}
|
||||
|
|
@ -617,6 +653,16 @@ function setGlobalTheme(t: Theme): void {
|
|||
let currentThemeName: string | undefined;
|
||||
let themeWatcher: fs.FSWatcher | undefined;
|
||||
let onThemeChangeCallback: (() => void) | undefined;
|
||||
const registeredThemes = new Map<string, Theme>();
|
||||
|
||||
export function setRegisteredThemes(themes: Theme[]): void {
|
||||
registeredThemes.clear();
|
||||
for (const theme of themes) {
|
||||
if (theme.name) {
|
||||
registeredThemes.set(theme.name, theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme(themeName?: string, enableWatcher: boolean = false): void {
|
||||
const name = themeName ?? getDefaultTheme();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue