{"_id":"@bytebuild/elyra-utils-settings","name":"@bytebuild/elyra-utils-settings","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@bytebuild/elyra-utils-settings","version":"0.1.0","type":"module","private":false,"license":"MIT","description":"Shared settings UI and config loader for Elyra Code extensions. Port of @aliou/pi-utils-settings.","author":{"name":"bytebuild","email":"dev-jad@outlook.com"},"keywords":["elyra","settings","config"],"homepage":"https://github.com/git-jad/elyra-utils-settings#readme","bugs":{"url":"https://github.com/git-jad/elyra-utils-settings/issues"},"exports":{".":"./src/index.ts"},"repository":{"type":"git","url":"git+https://github.com/git-jad/elyra-utils-settings.git"},"publishConfig":{"access":"public"},"elyra":{"skills":["skills"]},"scripts":{"typecheck":"tsc --noEmit","lint":"biome check .","format":"biome check --write .","test":"vitest run","test:watch":"vitest","changeset":"changeset","version":"changeset version","release":"pnpm changeset publish"},"dependencies":{"@aliou/pi-utils-ui":"^0.4.1"},"peerDependencies":{"@earendil-works/pi-coding-agent":"*"},"peerDependenciesMeta":{"@earendil-works/pi-coding-agent":{"optional":true}},"devDependencies":{"@aliou/pi-utils-ui":"^0.4.1","@aliou/biome-plugins":"^0.8.1","@biomejs/biome":"^2.4.15","@changesets/cli":"^2.27.11","@earendil-works/pi-coding-agent":"npm:@elyracode/coding-agent@^0.9.9","@earendil-works/pi-tui":"npm:@elyracode/tui@^0.9.9","@types/node":"^25.0.10","typescript":"^5.9.3","vitest":"^4.0.18"},"packageManager":"pnpm@10.26.1","_id":"@bytebuild/elyra-utils-settings@0.1.0","gitHead":"0c5d40447d794693feeda97f08f8ab1857a43e72","_nodeVersion":"22.22.3","_npmVersion":"10.9.8","dist":{"integrity":"sha512-rnqRhtYYnZ9lyusuYSb7ET5jnGpPWueBmebSOASD1nkspIBzeL7FPQxoRoklHpHFtyjMebGZZANOCYRfTb7xMQ==","shasum":"4a05080f2eaeea7bf041d3aaf1ee2385907a124e","tarball":"https://registry.npmjs.org/@bytebuild/elyra-utils-settings/-/elyra-utils-settings-0.1.0.tgz","fileCount":23,"unpackedSize":200361,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCI5hHgwpx4H8sLlbWm86Not1XOsiItcjc6jfEoZ+bq/QIgbXXfwc8jL2ykGR3XhnXqpiABWyi3rWQpQN0Gvq4TPRE="}]},"_npmUser":{"name":"bytebuild","email":"dev-jad@outlook.com"},"directories":{},"maintainers":[{"name":"bytebuild","email":"dev-jad@outlook.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/elyra-utils-settings_0.1.0_1782301853155_0.22034005577237958"},"_hasShrinkwrap":false}},"time":{"created":"2026-06-24T11:50:52.874Z","0.1.0":"2026-06-24T11:50:53.287Z","modified":"2026-06-24T11:50:53.557Z"},"maintainers":[{"name":"bytebuild","email":"dev-jad@outlook.com"}],"description":"Shared settings UI and config loader for Elyra Code extensions. Port of @aliou/pi-utils-settings.","homepage":"https://github.com/git-jad/elyra-utils-settings#readme","keywords":["elyra","settings","config"],"repository":{"type":"git","url":"git+https://github.com/git-jad/elyra-utils-settings.git"},"author":{"name":"bytebuild","email":"dev-jad@outlook.com"},"bugs":{"url":"https://github.com/git-jad/elyra-utils-settings/issues"},"license":"MIT","readme":"# @bytebuild/elyra-utils-settings\n\nShared settings infrastructure for [Elyra Code](https://elyracode.com) extensions. Provides config loading, a settings UI command with scope tabs plus optional extra tabs, and reusable TUI components. Port of [`@aliou/pi-utils-settings`](https://github.com/aliou/pi-utils-settings).\n\nThis is a utility library, not an Elyra Code extension. It is meant to be used as a dependency by extensions that need a settings UI or JSON config management.\n\n## Install\n\n```bash\npnpm add @bytebuild/elyra-utils-settings\n```\n\n## API\n\n### ConfigLoader\n\nGeneric JSON config loader with global + local (project) scopes, deep merge, and versioned migrations.\n\n```typescript\nimport { ConfigLoader, type Migration } from \"@bytebuild/elyra-utils-settings\";\n\ninterface MyConfig {\n  features?: { darkMode?: boolean };\n}\n\ninterface ResolvedConfig {\n  features: { darkMode: boolean };\n}\n\nconst migrations: Migration<MyConfig>[] = [\n  {\n    name: \"v1-upgrade\",\n    shouldRun: (config) => !config.features,\n    run: (config, _filePath) => ({ ...config, features: {} }),\n  },\n];\n\nconst configLoader = new ConfigLoader<MyConfig, ResolvedConfig>(\n  \"my-extension\", // reads ~/.pi/agent/extensions/my-extension.json + .pi/extensions/my-extension.json\n  { features: { darkMode: false } }, // defaults\n  { migrations },\n);\n\nawait configLoader.load();\nconst config = configLoader.getConfig(); // ResolvedConfig (defaults merged with global + local)\n```\n\n#### JSON Schema support\n\n`ConfigLoader` can inject a `$schema` field into settings files, giving editors autocomplete and validation. Pair it with `buildSchemaUrl` and auto-generated schemas from `ts-json-schema-generator`.\n\n```typescript\nimport { ConfigLoader, buildSchemaUrl } from \"@bytebuild/elyra-utils-settings\";\nimport pkg from \"./package.json\";\n\nconst schemaUrl = buildSchemaUrl(pkg.name, pkg.version);\n\n// For schemas hosted outside npm/unpkg, use a custom template:\nconst githubSchemaUrl = buildSchemaUrl(\"aliou/my-extension\", \"v1.0.0\", {\n  template: \"https://raw.githubusercontent.com/{packageName}/{version}/{schemaPath}\",\n});\n\nconst loader = new ConfigLoader<MyConfig, ResolvedConfig>(\n  \"my-extension\",\n  defaults,\n  { schemaUrl },\n);\n```\n\nWhen `schemaUrl` is set, `save()` writes `$schema` as the first key in the JSON file and `load()` strips it before returning config to callers.\n\nTo generate the schema from your `TConfig` type, add these scripts to your extension's `package.json`:\n\n```json\n{\n  \"gen:schema\": \"ts-json-schema-generator --path src/config.ts --type MyConfig --no-type-check -o schema.json\",\n  \"check:schema\": \"ts-json-schema-generator --path src/config.ts --type MyConfig --no-type-check -o /tmp/schema-check.json && diff -q schema.json /tmp/schema-check.json\"\n}\n```\n\nRun `pnpm gen:schema` to produce `schema.json`, commit it, and add `\"schema.json\"` to `files` in `package.json` so it ships with your npm package. Add `check:schema` to CI to catch drift. If the extension is not published to npm, commit `schema.json` somewhere public and pass a custom `template` or `baseUrl` to `buildSchemaUrl`.\n\nAn optional `afterMerge` hook runs after the deep merge for logic that can't be expressed as a simple merge (e.g., one field replacing another):\n\n```typescript\nnew ConfigLoader(\"my-ext\", defaults, {\n  afterMerge: (resolved, global, local, memory) => {\n    if (local?.customField) {\n      resolved.derivedField = local.customField;\n    }\n    return resolved;\n  },\n});\n```\n\n### registerSettingsCommand\n\nCreates a `/name:settings` command with scope tabs (Global/Local/Memory), draft-based editing, and Ctrl+S to save.\n\nAll changes (boolean toggles, enum cycling, submenu edits) are held in memory as drafts. Nothing is written to disk until the user presses Ctrl+S. Esc exits without saving by default. Dirty tabs show a `*` marker. Use `onBeforeClose` to intercept Esc, for example to confirm discarding unsaved drafts.\n\n```typescript\nimport { registerSettingsCommand, type SettingsSection } from \"@bytebuild/elyra-utils-settings\";\n\nregisterSettingsCommand<MyConfig, ResolvedConfig>(pi, {\n  commandName: \"my-ext:settings\",\n  title: \"My Extension Settings\",\n  configStore: configLoader, // implements ConfigStore interface\n  buildSections: (tabConfig, resolved, { setDraft, theme }) => [\n    {\n      label: \"General\",\n      items: [\n        {\n          id: \"features.darkMode\",\n          label: \"Dark mode\",\n          description: theme.fg(\"dim\", \"Enable dark mode\"),\n          currentValue: (tabConfig?.features?.darkMode ?? resolved.features.darkMode) ? \"on\" : \"off\",\n          values: [\"on\", \"off\"],\n        },\n      ],\n    },\n  ],\n  // --- Optional: Custom change handler ---\n  // The default handler stores all values as raw strings (\"on\"/\"off\", \"pnpm\", etc).\n  // Use onSettingChange to convert display values to the correct storage types:\n  // - Booleans: newValue === \"on\" -> true\n  // - Numbers: Number.parseInt(newValue, 10)\n  // Return null to fall through to the default string storage.\n  onSettingChange: (id, newValue, config) => {\n    const updated = structuredClone(config);\n    if (id === \"features.darkMode\") {\n      updated.features = { ...updated.features, darkMode: newValue === \"on\" };\n      return updated;\n    }\n    return null; // Fall through for other fields\n  },\n  // Optional: return false to keep the settings UI open on Esc.\n  onBeforeClose: (isDirty) => !isDirty,\n});\n```\n\nYou can also add non-scope top-level tabs with `extraTabs`:\n\n```typescript\nimport { registerSettingsCommand, type ExtraSettingsTab } from \"@bytebuild/elyra-utils-settings\";\n\nconst extraTabs: ExtraSettingsTab<MyConfig, ResolvedConfig>[] = [\n  {\n    id: \"examples\",\n    label: \"Examples\",\n    buildSections: ({ resolved, getRawForScope, enabledScopes }) => {\n      const globalConfig = getRawForScope(\"global\");\n      return [\n        {\n          label: \"Examples\",\n          items: [\n            {\n              id: \"example.enabledScopes\",\n              label: \"Enabled scopes\",\n              currentValue: enabledScopes.join(\", \"),\n            },\n            {\n              id: \"example.darkModeDefault\",\n              label: \"Dark mode default\",\n              currentValue: resolved.features.darkMode ? \"on\" : \"off\",\n            },\n            {\n              id: \"example.globalPresent\",\n              label: \"Global config\",\n              currentValue: globalConfig ? \"present\" : \"missing\",\n              description: \"Read-only info tab not tied to a scope.\",\n            },\n          ],\n        },\n      ];\n    },\n  },\n];\n```\n\n`Ctrl+S` behavior stays the same: only dirty scope drafts are saved. Extra tabs can update drafts by calling `setDraftForScope(...)` from submenu callbacks.\n\nFor value-cycling items (`values`) in an extra tab, add `onSettingChange` to the extra tab and choose the target scope explicitly. `applySettingChangeToScope(...)` reuses the command-level `onSettingChange` handler, falling back to the default dotted-path string storage when that handler returns `null`.\n\n```typescript\nconst extraTabs: ExtraSettingsTab<MyConfig, ResolvedConfig>[] = [\n  {\n    id: \"presets\",\n    label: \"Presets\",\n    buildSections: ({ getDraftForScope, getRawForScope }) => {\n      const config = getDraftForScope(\"global\") ?? getRawForScope(\"global\");\n      return [\n        {\n          label: \"Presets\",\n          items: [\n            {\n              id: \"features.darkMode\",\n              label: \"Dark mode\",\n              currentValue: config?.features?.darkMode ? \"on\" : \"off\",\n              values: [\"on\", \"off\"],\n            },\n          ],\n        },\n      ];\n    },\n    onSettingChange: (id, newValue, ctx) => {\n      ctx.applySettingChangeToScope(\"global\", id, newValue);\n    },\n  },\n];\n```\n\n`buildSections` ctx now includes `theme`, which is both a `SettingsListTheme` and full pi `Theme`. This means you can use list helpers (`label`, `value`, `hint`, ...) and pass the same object to components that require full `Theme`.\n\n```typescript\nimport { Wizard } from \"@bytebuild/elyra-utils-settings\";\n\nbuildSections: (_tabConfig, _resolved, ctx) => [\n  {\n    label: \"Setup\",\n    items: [\n      {\n        id: \"setup.wizard\",\n        label: \"Run setup\",\n        currentValue: ctx.theme.fg(\"accent\", \"open\"),\n        submenu: (_value, done) =>\n          new Wizard({\n            title: \"Setup\",\n            theme: ctx.theme,\n            steps: [{ label: \"Step\", build: () => ({ render: () => [ctx.theme.hint(\"Ready\")], handleInput: () => {} }) }],\n            onComplete: () => done(\"done\"),\n            onCancel: () => done(undefined),\n          }),\n      },\n    ],\n  },\n];\n```\n\n### Submenu support\n\nItems can open submenus by providing a `submenu` factory. The factory receives the current value, a `done` callback, and a `{ requestRender }` context so async submenus can trigger a redraw. Use `setDraft` inside submenu `onSave` to keep changes in the draft (same save model as simple values):\n\n```typescript\nimport { ArrayEditor, setNestedValue } from \"@bytebuild/elyra-utils-settings\";\n\n{\n  id: \"tags\",\n  label: \"Tags\",\n  currentValue: `${tags.length} items`,\n  submenu: (_val, done, _ctx) => {\n    let latest = [...tags];\n    return new ArrayEditor({\n      label: \"Tags\",\n      items: [...tags],\n      theme: ctx.theme,\n      onSave: (items) => {\n        latest = items;\n        const updated = structuredClone(tabConfig ?? {}) as MyConfig;\n        setNestedValue(updated, \"tags\", items);\n        setDraft(updated);\n      },\n      onDone: () => done(`${latest.length} items`),\n    });\n  },\n}\n```\n\nFor submenus that load data asynchronously, call `ctx.requestRender()` once the real editor is ready. The render hook is wired automatically by `registerSettingsCommand`; standalone `SectionedSettings` users can pass `requestRender` in `SectionedSettingsOptions`.\n\n```typescript\nimport type { Component } from \"@earendil-works/pi-tui\";\nimport { Key, matchesKey } from \"@earendil-works/pi-tui\";\nimport { FuzzySelector } from \"@bytebuild/elyra-utils-settings\";\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nasync function loadPresets(): Promise<string[]> {\n  // Simulate a network or subprocess call.\n  await sleep(2000);\n  return [\"dark\", \"light\", \"solarized-dark\"];\n}\n\n{\n  id: \"remote.presets\",\n  label: \"Remote presets\",\n  currentValue: \"loading\",\n  submenu: (_val, done, { requestRender }) => {\n    class AsyncPresetPicker implements Component {\n      private editor: Component | null = null;\n\n      constructor() {\n        void loadPresets().then((presets) => {\n          this.editor = new FuzzySelector({\n            label: \"Preset\",\n            items: presets,\n            theme: ctx.theme,\n            onSelect: (selected) => {\n              const updated = structuredClone(tabConfig ?? {}) as MyConfig;\n              setNestedValue(updated, \"appearance.theme\", selected);\n              setDraft(updated);\n              done(selected);\n            },\n            onDone: () => done(undefined),\n          });\n          requestRender();\n        });\n      }\n\n      render(width: number): string[] {\n        return this.editor?.render(width) ?? [ctx.theme.hint(\"  (loading presets...)\")];\n      }\n\n      handleInput(data: string): void {\n        if (this.editor === null && matchesKey(data, Key.escape)) {\n          done(undefined);\n          return;\n        }\n        this.editor?.handleInput?.(data);\n      }\n\n      invalidate(): void {\n        this.editor?.invalidate?.();\n      }\n    }\n\n    return new AsyncPresetPicker();\n  },\n}\n```\n\n### SectionedSettings vs SettingsDetailEditor\n\nUse **SectionedSettings** alone when each row can be edited in one step (toggle, enum cycle, or a simple submenu).\n\nUse **SectionedSettings + SettingsDetailEditor** when a selected row needs a focused second-level panel with multiple editable fields.\n\n`SettingsDetailEditor` is data-driven. You pass field descriptors with getters/setters and optional nested submenu callbacks. The component owns keyboard navigation and rendering only.\n\n```typescript\nimport {\n  ArrayEditor,\n  SettingsDetailEditor,\n  type SettingsDetailField,\n} from \"@bytebuild/elyra-utils-settings\";\nimport { getSettingsListTheme } from \"@earendil-works/pi-coding-agent\";\n\nconst fields: SettingsDetailField[] = [\n  {\n    id: \"autoSave\",\n    type: \"boolean\",\n    label: \"Auto save\",\n    getValue: () => editor.autoSave,\n    setValue: (next) => {\n      editor.autoSave = next;\n    },\n  },\n  {\n    id: \"tabSize\",\n    type: \"enum\",\n    label: \"Tab size\",\n    getValue: () => String(editor.tabSize),\n    setValue: (next) => {\n      editor.tabSize = Number.parseInt(next, 10);\n    },\n    options: [\"2\", \"4\", \"8\"],\n  },\n  {\n    id: \"favorites\",\n    type: \"submenu\",\n    label: \"Favorites\",\n    getValue: () => `${favorites.length} items`,\n    submenu: (done) =>\n      new ArrayEditor({\n        label: \"Favorites\",\n        items: [...favorites],\n        theme: getSettingsListTheme(),\n        onSave: (items) => {\n          favorites = items;\n        },\n        onDone: () => done(`${favorites.length} items`),\n      }),\n  },\n  {\n    id: \"clear\",\n    type: \"action\",\n    label: \"Clear favorites\",\n    getValue: () => \"destructive\",\n    onConfirm: () => {\n      favorites = [];\n    },\n    confirmMessage: \"Clear all favorites? This cannot be undone.\",\n  },\n];\n\nconst detail = new SettingsDetailEditor({\n  title: \"Editor details\",\n  fields,\n  theme: getSettingsListTheme(),\n  onDone: (summary) => done(summary),\n  getDoneSummary: () => `${favorites.length} items`,\n});\n```\n\n### ConfigStore interface\n\nExtensions with custom config loaders can implement `ConfigStore` directly instead of using `ConfigLoader`:\n\n```typescript\ninterface ConfigStore<TConfig, TResolved> {\n  getConfig(): TResolved;\n  getRawConfig(scope: Scope): TConfig | null;\n  hasScope(scope: Scope): boolean;\n  hasConfig(scope: Scope): boolean;\n  getEnabledScopes(): Scope[];\n  save(scope: Scope, config: TConfig): Promise<void>;\n}\n```\n\n### Components\n\n- **SectionedSettings**: Grouped settings list with search filtering and cursor preservation on update.\n- **SettingsDetailEditor**: Focused second-level editor for one selected item (text, enum, boolean, nested submenu, destructive action).\n- **ArrayEditor**: String array editor with add/remove/reorder.\n- **PathArrayEditor**: Path-focused array editor with Tab completion in add/edit mode.\n- **FuzzySelector**: Fuzzy-searchable single-select list.\n- **FuzzyMultiSelector**: Fuzzy-searchable multi-select checklist with locked/recommended items and sub-options.\n- **Wizard**: Multi-step setup component with tabbed navigation, progress indicators, and bordered frame.\n\n### Helpers\n\n- `setNestedValue(obj, \"a.b.c\", value)`: Set a deeply nested value by dot-separated path.\n- `getNestedValue(obj, \"a.b.c\")`: Get a deeply nested value by dot-separated path.\n- `getSettingsTheme(theme)`: Build a combined settings theme (`SettingsTheme`) usable by both settings-list components and full-theme components like `Wizard`.\n- `buildSchemaUrl(packageName, version, options?)`: Build a URL to a JSON Schema file for `$schema` injection (defaults to unpkg, supports custom `baseUrl` or `template`).\n\n## Exports\n\n```typescript\nexport {\n  ArrayEditor,\n  type ArrayEditorOptions,\n} from \"./src/components/array-editor\";\nexport {\n  FuzzyMultiSelector,\n  type FuzzyMultiSelectorItem,\n  type FuzzyMultiSelectorOptions,\n  type FuzzyMultiSelectorSubOption,\n} from \"./src/components/fuzzy-multi-selector\";\nexport {\n  FuzzySelector,\n  type FuzzySelectorOptions,\n} from \"./src/components/fuzzy-selector\";\nexport {\n  PathArrayEditor,\n  type PathArrayEditorOptions,\n} from \"./src/components/path-array-editor\";\nexport {\n  SectionedSettings,\n  type SectionedSettingsOptions,\n  type SettingsSection,\n} from \"./src/components/sectioned-settings\";\nexport {\n  type SettingsDetailActionField,\n  type SettingsDetailBooleanField,\n  SettingsDetailEditor,\n  type SettingsDetailEditorOptions,\n  type SettingsDetailEnumField,\n  type SettingsDetailField,\n  type SettingsDetailSubmenuField,\n  type SettingsDetailTextField,\n} from \"./src/components/settings-detail-editor\";\nexport {\n  Wizard,\n  type WizardOptions,\n  type WizardStep,\n  type WizardStepContext,\n} from \"./src/components/wizard\";\nexport {\n  ConfigLoader,\n  type ConfigStore,\n  type Migration,\n  type Scope,\n} from \"./src/config-loader\";\nexport { getNestedValue, setNestedValue } from \"./src/helpers\";\nexport { type BuildSchemaUrlOptions, buildSchemaUrl } from \"./src/schema\";\nexport {\n  type ExtraSettingsTab,\n  type ExtraSettingsTabChangeContext,\n  type ExtraSettingsTabContext,\n  registerSettingsCommand,\n  type SettingsCommandOptions,\n} from \"./src/settings-command\";\nexport { getSettingsTheme, type SettingsTheme } from \"./src/theme\";\n```","readmeFilename":"README.md","_rev":"1-888a72ec29b5a624dac09b752f8ffeb7"}