import { defineExtensionSettings } from "@zigai/pi-extension-settings"; import { loadPiExtensionSettings } from "@zigai/pi-extension-settings/pi"; import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { Type, type Static, type TSchema } from "typebox"; import { Value } from "typebox/value"; import type { StatusBarConfig } from "./status-bar-api.ts"; const RIGHT_MESSAGES_SETTINGS_KEY = "rightMessages"; const DEFAULT_RIGHT_MESSAGE_INTERVAL_MS = 10_000; const DEFAULT_RIGHT_MESSAGE_MIN_GAP = 4; const DEFAULT_RIGHT_MESSAGE_SCROLL_COLUMN_INTERVAL_MS = 120; const DEFAULT_RIGHT_MESSAGE_MIN_SCROLL_CYCLES = 1; const spinnerSettingsSchema = Type.Object( { frames: Type.Optional( Type.Array(Type.String(), { description: "Spinner frames displayed while Pi is active.", }), ), }, { additionalProperties: false }, ); const timerSettingsSchema = Type.Object( { visible: Type.Boolean({ default: true, description: "Show the active-run timer.", }), paused: Type.Boolean({ default: false, description: "Display the active-run timer as paused.", }), }, { default: {}, additionalProperties: false }, ); const activeSettingsSchema = Type.Object( { text: Type.Optional(Type.String({ description: "Custom active status text." })), spinner: Type.Optional(spinnerSettingsSchema), timer: timerSettingsSchema, }, { default: {}, additionalProperties: false }, ); const idleSettingsSchema = Type.Object( { text: Type.Optional(Type.String({ description: "Custom idle status text." })), visible: Type.Boolean({ default: true, description: "Show the status bar while Pi is idle.", }), showLastRunSummary: Type.Boolean({ default: true, description: "Show the previous run summary while idle.", }), }, { default: {}, additionalProperties: false }, ); export const statusBarSettingsDefinition = defineExtensionSettings({ id: "pi-status-bar", title: "Pi Status Bar", description: "Settings for active, idle, and rotating status-bar content.", schemaId: "https://raw.githubusercontent.com/zigai/pi-tweaks/master/packages/pi-status-bar/config.schema.json", schema: Type.Object( { statusBar: Type.Object( { active: activeSettingsSchema, idle: idleSettingsSchema, }, { default: {}, additionalProperties: false }, ), rightMessages: Type.Object( { enabled: Type.Boolean({ default: false, description: "Enable rotating messages on the right side.", }), intervalMs: Type.Integer({ minimum: 1, default: DEFAULT_RIGHT_MESSAGE_INTERVAL_MS, description: "Delay between rotating messages in milliseconds.", }), minGap: Type.Integer({ minimum: 0, default: DEFAULT_RIGHT_MESSAGE_MIN_GAP, description: "Minimum spaces between repeated scrolling messages.", }), minScrollCycles: Type.Integer({ minimum: 1, default: DEFAULT_RIGHT_MESSAGE_MIN_SCROLL_CYCLES, description: "Minimum completed scroll cycles before advancing.", }), scrollColumnIntervalMs: Type.Integer({ minimum: 1, default: DEFAULT_RIGHT_MESSAGE_SCROLL_COLUMN_INTERVAL_MS, description: "Delay between horizontal scroll columns in milliseconds.", }), dimmed: Type.Boolean({ default: true, description: "Render rotating messages with dim styling.", }), italic: Type.Boolean({ default: true, description: "Render rotating messages with italic styling.", }), messages: Type.Array(Type.String(), { default: [], description: "Inline rotating status messages.", }), messagesFile: Type.Optional( Type.String({ minLength: 1, "x-control": "path", description: "Path to a newline-delimited messages file.", }), ), }, { default: {}, additionalProperties: false }, ), }, { additionalProperties: false }, ), }); export default statusBarSettingsDefinition; export type RightMessagesConfig = { readonly enabled: boolean; readonly intervalMs: number; readonly minGap: number; readonly minScrollCycles: number; readonly scrollColumnIntervalMs: number; readonly dimmed: boolean; readonly italic: boolean; readonly messages: readonly string[]; }; export type StatusBarResolvedConfig = { readonly statusBar: StatusBarConfig; readonly rightMessages: RightMessagesConfig; }; export type LoadedStatusBarConfig = { readonly config: StatusBarResolvedConfig; readonly errors: readonly string[]; }; export type StatusBarSettingsSource = { readonly label: string; readonly baseDir: string; readonly settings: unknown; }; type MessageFileReference = { readonly path: string; readonly baseDir: string; readonly label: string; }; type RightMessagesSettings = { readonly enabled?: boolean; readonly intervalMs?: number; readonly minGap?: number; readonly minScrollCycles?: number; readonly scrollColumnIntervalMs?: number; readonly dimmed?: boolean; readonly italic?: boolean; readonly messages?: readonly string[]; readonly messagesFile?: MessageFileReference; }; type StatusBarSettings = { readonly $schema?: string; readonly statusBar?: StatusBarConfig; readonly rightMessages?: RightMessagesSettings; }; type MutableStatusBarConfig = { active?: { text?: string; spinner?: { frames?: readonly string[]; }; timer?: { visible?: boolean; paused?: boolean; }; }; idle?: { text?: string; visible?: boolean; showLastRunSummary?: boolean; }; }; const StatusBarSpinnerConfigSchema = Type.Object( { frames: Type.Optional(Type.Array(Type.String())), }, { additionalProperties: false }, ); const StatusBarTimerConfigSchema = Type.Object( { visible: Type.Optional(Type.Boolean()), paused: Type.Optional(Type.Boolean()), }, { additionalProperties: false }, ); const StatusBarActiveConfigSchema = Type.Object( { text: Type.Optional(Type.String()), spinner: Type.Optional(StatusBarSpinnerConfigSchema), timer: Type.Optional(StatusBarTimerConfigSchema), }, { additionalProperties: false }, ); const StatusBarIdleConfigSchema = Type.Object( { text: Type.Optional(Type.String()), visible: Type.Optional(Type.Boolean()), showLastRunSummary: Type.Optional(Type.Boolean()), }, { additionalProperties: false }, ); const StatusBarConfigSchema = Type.Object( { active: Type.Optional(StatusBarActiveConfigSchema), idle: Type.Optional(StatusBarIdleConfigSchema), }, { additionalProperties: false }, ); const RightMessagesConfigSchema = Type.Object( { enabled: Type.Optional(Type.Boolean()), intervalMs: Type.Optional(Type.Integer({ minimum: 1 })), minGap: Type.Optional(Type.Integer({ minimum: 0 })), minScrollCycles: Type.Optional(Type.Integer({ minimum: 1 })), scrollColumnIntervalMs: Type.Optional(Type.Integer({ minimum: 1 })), dimmed: Type.Optional(Type.Boolean()), italic: Type.Optional(Type.Boolean()), messages: Type.Optional(Type.Array(Type.String())), messagesFile: Type.Optional(Type.String({ minLength: 1 })), }, { additionalProperties: false }, ); const StatusBarConfigFileSchema = Type.Object( { $schema: Type.Optional(Type.String()), statusBar: Type.Optional(StatusBarConfigSchema), rightMessages: Type.Optional(RightMessagesConfigSchema), }, { additionalProperties: false }, ); type ParsedStatusBarResolvedConfig = Static; export const DEFAULT_RIGHT_MESSAGES_CONFIG: RightMessagesConfig = { enabled: false, intervalMs: DEFAULT_RIGHT_MESSAGE_INTERVAL_MS, minGap: DEFAULT_RIGHT_MESSAGE_MIN_GAP, minScrollCycles: DEFAULT_RIGHT_MESSAGE_MIN_SCROLL_CYCLES, scrollColumnIntervalMs: DEFAULT_RIGHT_MESSAGE_SCROLL_COLUMN_INTERVAL_MS, dimmed: true, italic: true, messages: [], }; export const DEFAULT_STATUS_BAR_CONFIG: StatusBarConfig = { active: { timer: { visible: true, paused: false, }, }, idle: { visible: true, showLastRunSummary: true, }, }; function sanitizeOptionalText(text: string | undefined): string | undefined { if (text === undefined) return undefined; const sanitized = text .replace(/[\r\n\t]/g, " ") .replace(/ +/g, " ") .trim(); if (sanitized.length === 0) return undefined; return sanitized; } function sanitizeOptionalFrames( frames: readonly string[] | undefined, ): readonly string[] | undefined { if (frames === undefined) return undefined; const sanitizedFrames: string[] = []; for (const frame of frames) { const sanitized = sanitizeOptionalText(frame); if (sanitized !== undefined) { sanitizedFrames.push(sanitized); } } if (sanitizedFrames.length === 0) return undefined; return sanitizedFrames; } function parseStatusBarConfigSettings( settings: Static | undefined, ): StatusBarConfig | undefined { if (settings === undefined) return undefined; const parsed = settings; const statusBar: MutableStatusBarConfig = {}; if (parsed.active !== undefined) { const active: NonNullable = {}; const text = sanitizeOptionalText(parsed.active.text); if (text !== undefined) { active.text = text; } const frames = sanitizeOptionalFrames(parsed.active.spinner?.frames); if (frames !== undefined) { active.spinner = { frames }; } if (parsed.active.timer !== undefined) { active.timer = parsed.active.timer; } statusBar.active = active; } if (parsed.idle !== undefined) { const idle: NonNullable = {}; const text = sanitizeOptionalText(parsed.idle.text); if (text !== undefined) { idle.text = text; } if (parsed.idle.visible !== undefined) { idle.visible = parsed.idle.visible; } if (parsed.idle.showLastRunSummary !== undefined) { idle.showLastRunSummary = parsed.idle.showLastRunSummary; } statusBar.idle = idle; } return statusBar; } function formatSchemaPath(instancePath: string): string { if (instancePath.length === 0) return "root"; return instancePath .slice(1) .split("/") .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) .join("."); } function parseSchema( schema: Schema, value: unknown, label: string, ): Static { const errors = [...Value.Errors(schema, value)]; if (errors.length > 0) { const messages = errors .slice(0, 10) .map((error) => `${formatSchemaPath(error.instancePath)} ${error.message}`); let suffix = ""; if (errors.length > messages.length) { suffix = `; and ${errors.length - messages.length} more`; } throw new Error(`${label} is invalid: ${messages.join("; ")}${suffix}`); } const parsed: unknown = Value.Parse(schema, value); // SAFETY: Value.Errors returned no schema violations, so Value.Parse returns // the TypeBox static type represented by the same schema. // oxlint-disable-next-line typescript/no-unsafe-return -- SAFETY: TypeBox exposes parsed schema output through a conditional static type that oxlint treats as any here. return parsed as Static; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function readOptionalBoolean( record: Record, key: keyof RightMessagesSettings, label: string, ): { value?: boolean; error?: string } { const value = record[key]; if (value === undefined) { return {}; } if (typeof value === "boolean") { return { value }; } return { error: `${label}.${key} must be a boolean.` }; } function readOptionalPositiveInteger( record: Record, key: keyof RightMessagesSettings, label: string, ): { value?: number; error?: string } { const value = record[key]; if (value === undefined) { return {}; } if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { return { error: `${label}.${key} must be a positive integer.` }; } return { value }; } function readOptionalNonNegativeInteger( record: Record, key: keyof RightMessagesSettings, label: string, ): { value?: number; error?: string } { const value = record[key]; if (value === undefined) { return {}; } if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { return { error: `${label}.${key} must be a non-negative integer.` }; } return { value }; } function isUnknownArray(value: unknown): value is unknown[] { return Array.isArray(value); } function readOptionalMessages( record: Record, label: string, ): { value?: readonly string[]; errors: string[] } { const value = record.messages; if (value === undefined) { return { errors: [] }; } if (!isUnknownArray(value)) { return { errors: [`${label}.messages must be an array of strings.`] }; } const messages: string[] = []; const errors: string[] = []; for (let index = 0; index < value.length; index += 1) { const entry = value[index]; if (typeof entry !== "string") { errors.push(`${label}.messages[${index}] must be a string.`); continue; } const message = entry.trim(); if (message.length > 0) { messages.push(message); } } return { value: messages, errors }; } function readOptionalMessagesFile( record: Record, label: string, baseDir: string, ): { value?: MessageFileReference; error?: string } { const value = record.messagesFile; if (value === undefined) { return {}; } if (typeof value !== "string" || value.trim().length === 0) { return { error: `${label}.messagesFile must be a non-empty string.` }; } return { value: { path: value.trim(), baseDir, label: `${label}.messagesFile`, }, }; } function parseRightMessagesSettings( settings: unknown, label: string, baseDir: string, ): { settings: RightMessagesSettings; errors: string[] } { if (settings === undefined) { return { settings: {}, errors: [] }; } if (!isRecord(settings)) { return { settings: {}, errors: [`${label}.${RIGHT_MESSAGES_SETTINGS_KEY} must be a JSON object.`], }; } const errors: string[] = []; const parsed: { enabled?: boolean; intervalMs?: number; minGap?: number; minScrollCycles?: number; scrollColumnIntervalMs?: number; dimmed?: boolean; italic?: boolean; messages?: readonly string[]; messagesFile?: MessageFileReference; } = {}; const rightMessagesLabel = `${label}.${RIGHT_MESSAGES_SETTINGS_KEY}`; const enabled = readOptionalBoolean(settings, "enabled", rightMessagesLabel); if (enabled.error !== undefined) { errors.push(enabled.error); } else if (enabled.value !== undefined) { parsed.enabled = enabled.value; } const intervalMs = readOptionalPositiveInteger(settings, "intervalMs", rightMessagesLabel); if (intervalMs.error !== undefined) { errors.push(intervalMs.error); } else if (intervalMs.value !== undefined) { parsed.intervalMs = intervalMs.value; } const minGap = readOptionalNonNegativeInteger(settings, "minGap", rightMessagesLabel); if (minGap.error !== undefined) { errors.push(minGap.error); } else if (minGap.value !== undefined) { parsed.minGap = minGap.value; } const minScrollCycles = readOptionalPositiveInteger( settings, "minScrollCycles", rightMessagesLabel, ); if (minScrollCycles.error !== undefined) { errors.push(minScrollCycles.error); } else if (minScrollCycles.value !== undefined) { parsed.minScrollCycles = minScrollCycles.value; } const scrollColumnIntervalMs = readOptionalPositiveInteger( settings, "scrollColumnIntervalMs", rightMessagesLabel, ); if (scrollColumnIntervalMs.error !== undefined) { errors.push(scrollColumnIntervalMs.error); } else if (scrollColumnIntervalMs.value !== undefined) { parsed.scrollColumnIntervalMs = scrollColumnIntervalMs.value; } const dimmed = readOptionalBoolean(settings, "dimmed", rightMessagesLabel); if (dimmed.error !== undefined) { errors.push(dimmed.error); } else if (dimmed.value !== undefined) { parsed.dimmed = dimmed.value; } const italic = readOptionalBoolean(settings, "italic", rightMessagesLabel); if (italic.error !== undefined) { errors.push(italic.error); } else if (italic.value !== undefined) { parsed.italic = italic.value; } const messages = readOptionalMessages(settings, rightMessagesLabel); errors.push(...messages.errors); if (messages.value !== undefined) { parsed.messages = messages.value; } const messagesFile = readOptionalMessagesFile(settings, rightMessagesLabel, baseDir); if (messagesFile.error !== undefined) { errors.push(messagesFile.error); } else if (messagesFile.value !== undefined) { parsed.messagesFile = messagesFile.value; } return { settings: parsed, errors }; } function parseStatusBarSettings( settings: unknown, label: string, baseDir: string, ): { settings: StatusBarSettings; errors: string[] } { let parsedConfig: ParsedStatusBarResolvedConfig; try { parsedConfig = parseSchema(StatusBarConfigFileSchema, settings, label); } catch (error: unknown) { let message: string; if (error instanceof Error) { message = error.message; } else { message = String(error); } return { settings: {}, errors: [message] }; } const parsed = parseRightMessagesSettings(parsedConfig.rightMessages, label, baseDir); return { settings: { statusBar: parseStatusBarConfigSettings(parsedConfig.statusBar), rightMessages: parsed.settings, }, errors: parsed.errors, }; } function mergeStatusBarConfig( current: StatusBarConfig | undefined, next: StatusBarConfig | undefined, ): StatusBarConfig | undefined { if (current === undefined) return next; if (next === undefined) return current; return { active: { ...current.active, ...next.active, spinner: { ...current.active?.spinner, ...next.active?.spinner, }, timer: { ...current.active?.timer, ...next.active?.timer, }, }, idle: { ...current.idle, ...next.idle, }, }; } function resolveConfiguredPath(path: string, baseDir: string): string { if (path === "~") { return homedir(); } if (path.startsWith("~/")) { return join(homedir(), path.slice(2)); } if (isAbsolute(path)) { return path; } return resolve(baseDir, path); } function parseMessagesFileContent(content: string): string[] { const messages: string[] = []; for (const line of content.split(/\r?\n/u)) { const message = line.trim(); if (message.length === 0) { continue; } if (message.startsWith("#")) { continue; } messages.push(message); } return messages; } function readMessagesFile(reference: MessageFileReference): { messages: string[]; error?: string } { const resolvedPath = resolveConfiguredPath(reference.path, reference.baseDir); try { const content = readFileSync(resolvedPath, "utf8"); return { messages: parseMessagesFileContent(content) }; } catch (cause: unknown) { let message: string; if (cause instanceof Error) { message = cause.message; } else { message = String(cause); } return { messages: [], error: `Failed to read ${reference.label} (${resolvedPath}): ${message}`, }; } } function buildStatusBarResolvedConfig(settings: StatusBarSettings): LoadedStatusBarConfig { const rightMessages = settings.rightMessages ?? {}; const errors: string[] = []; const messages: string[] = []; if (rightMessages.enabled === false) { return { config: { statusBar: settings.statusBar ?? DEFAULT_STATUS_BAR_CONFIG, rightMessages: { enabled: false, intervalMs: rightMessages.intervalMs ?? DEFAULT_RIGHT_MESSAGES_CONFIG.intervalMs, minGap: rightMessages.minGap ?? DEFAULT_RIGHT_MESSAGES_CONFIG.minGap, minScrollCycles: rightMessages.minScrollCycles ?? DEFAULT_RIGHT_MESSAGES_CONFIG.minScrollCycles, scrollColumnIntervalMs: rightMessages.scrollColumnIntervalMs ?? DEFAULT_RIGHT_MESSAGES_CONFIG.scrollColumnIntervalMs, dimmed: rightMessages.dimmed ?? DEFAULT_RIGHT_MESSAGES_CONFIG.dimmed, italic: rightMessages.italic ?? DEFAULT_RIGHT_MESSAGES_CONFIG.italic, messages: [], }, }, errors, }; } if (rightMessages.messages !== undefined) { messages.push(...rightMessages.messages); } if (rightMessages.messagesFile !== undefined) { const loaded = readMessagesFile(rightMessages.messagesFile); messages.push(...loaded.messages); if (loaded.error !== undefined) { errors.push(loaded.error); } } let enabled = messages.length > 0; if (rightMessages.enabled !== undefined) { enabled = rightMessages.enabled; } return { config: { statusBar: settings.statusBar ?? DEFAULT_STATUS_BAR_CONFIG, rightMessages: { enabled, intervalMs: rightMessages.intervalMs ?? DEFAULT_RIGHT_MESSAGES_CONFIG.intervalMs, minGap: rightMessages.minGap ?? DEFAULT_RIGHT_MESSAGES_CONFIG.minGap, minScrollCycles: rightMessages.minScrollCycles ?? DEFAULT_RIGHT_MESSAGES_CONFIG.minScrollCycles, scrollColumnIntervalMs: rightMessages.scrollColumnIntervalMs ?? DEFAULT_RIGHT_MESSAGES_CONFIG.scrollColumnIntervalMs, dimmed: rightMessages.dimmed ?? DEFAULT_RIGHT_MESSAGES_CONFIG.dimmed, italic: rightMessages.italic ?? DEFAULT_RIGHT_MESSAGES_CONFIG.italic, messages, }, }, errors, }; } /** * Resolves status bar settings from already-parsed Pi settings objects in precedence order. */ export function resolveStatusBarResolvedConfig( settingsSources: readonly StatusBarSettingsSource[], ): LoadedStatusBarConfig { let mergedSettings: { statusBar?: StatusBarConfig; rightMessages?: RightMessagesSettings; } = {}; const errors: string[] = []; for (const source of settingsSources) { const parsed = parseStatusBarSettings(source.settings, source.label, source.baseDir); mergedSettings.statusBar = mergeStatusBarConfig( mergedSettings.statusBar, parsed.settings.statusBar, ); if (parsed.settings.rightMessages !== undefined) { Object.assign((mergedSettings.rightMessages ??= {}), parsed.settings.rightMessages); } errors.push(...parsed.errors); } const loaded = buildStatusBarResolvedConfig(mergedSettings); return { config: loaded.config, errors: [...errors, ...loaded.errors], }; } /** Load status-bar settings from global and trusted-project extension settings. */ export function loadStatusBarSettings(cwd: string, projectTrusted: boolean): LoadedStatusBarConfig { const settings = loadPiExtensionSettings( statusBarSettingsDefinition, { cwd, isProjectTrusted: () => projectTrusted }, { bundledSchema: { kind: "url", url: new URL("../config.schema.json", import.meta.url), }, }, ); const settingsSources: StatusBarSettingsSource[] = []; if (settings.globalSettingsLayer !== undefined) { settingsSources.push({ label: settings.globalConfigPath, baseDir: dirname(settings.globalConfigPath), settings: settings.globalSettingsLayer, }); } if (settings.projectSettingsLayer !== undefined && settings.projectConfigPath !== undefined) { settingsSources.push({ label: settings.projectConfigPath, baseDir: cwd, settings: settings.projectSettingsLayer, }); } const loaded = resolveStatusBarResolvedConfig(settingsSources); return { config: loaded.config, errors: [...settings.diagnostics.map((diagnostic) => diagnostic.message), ...loaded.errors], }; }