avtc-pi-settings-ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/schema.ts ADDED
@@ -0,0 +1,144 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Schema-driven settings type definitions.
6
+ *
7
+ * A setting is fully described by a single `SettingSchema` entry.
8
+ * The generic infrastructure auto-generates UI, persistence, normalization,
9
+ * and validation from the schema alone.
10
+ */
11
+
12
+ /** Setting type hint — drives validation and display formatting.
13
+ * A bare type-id `string`: the 7 built-in TypeDefinition ids (`number`, `duration`, `compact-threshold`,
14
+ * `string`, `boolean`, `model`, `thinking-level`) plus any custom type registered via `registerTypeDefinition`. */
15
+ export type SettingType = string;
16
+
17
+ /** The value slot of a preset — the raw typed value a preset selects. */
18
+ export type PresetValue = string | number | boolean | null;
19
+
20
+ /** Ordered preset pair: display label paired with its raw typed value. */
21
+ export type PresetPair = readonly [string, PresetValue];
22
+
23
+ /** A preset source element: a full [label, value] pair, OR a bare value. Bare values are
24
+ * auto-paired at the resolution seam — a bare string → type-aware [s, parse(s)] (duration
25
+ * "30m"→1800000, string→identity); a bare number/boolean/null → [String(v), v]. Mixed arrays
26
+ * ([ ["off", null], "5m", "10m"]) are allowed. */
27
+ export type PresetElement = PresetPair | PresetValue;
28
+
29
+ /** Source of presets for a setting: a static element array OR a resolver function.
30
+ * Declared at two levels — a type-def default (`TypeDefinition.presets`) overridable by the
31
+ * setting's own `presets` (authoritative). The static array is resolved at the gate; the
32
+ * resolver is resolved once at modal-open (the gate cannot call a resolver, so it treats a
33
+ * resolver as no presets at load). */
34
+ export type PresetsSource =
35
+ | readonly PresetElement[]
36
+ | (() => readonly PresetElement[] | Promise<readonly PresetElement[]>);
37
+
38
+ /** Schema for a single setting. */
39
+ export interface SettingSchema<T = unknown> {
40
+ /** Unique key (e.g. "codeReviewLoops") */
41
+ id: string;
42
+ /** Display label ("Code review loops") */
43
+ label: string;
44
+ /** Shown when selected in settings UI */
45
+ description?: string;
46
+ /** Validation/display hint */
47
+ type: SettingType;
48
+ /** Inclusive numeric floor (for `number`/`duration` types whose `TypeDefinition.bounded` is true). */
49
+ min?: number;
50
+ /** Inclusive numeric ceiling (for bounded numeric/duration types). */
51
+ max?: number;
52
+ /** Default value. Use `undefined` for intentionally absent defaults. */
53
+ defaultValue: T;
54
+ /**
55
+ * Presets for this setting. When set, this OVERRIDES the type-def's default presets
56
+ * (replace, not merge). When absent, the type-def's default presets (if any) apply.
57
+ * Either level may be a static pair array (resolved at the gate) or a resolver function
58
+ * (resolved once at modal-open). Order is the display order — immune to JS integer-index
59
+ * key reordering. The library derives labels, internal value strings, the label→value map,
60
+ * AND the null display label (the label of the pair whose value is null) from this in one
61
+ * atomic pass.
62
+ */
63
+ presets?: PresetsSource;
64
+ /** Backward-compat field names (old keys that map to this setting's canonical id). */
65
+ aliases?: string[];
66
+ /** Legacy value aliases: old value → current raw value. Resolved before value-based matching. */
67
+ valueAliases?: Record<string, string | number | boolean | null>;
68
+ }
69
+
70
+ /** Schema for a settings tab. */
71
+ export interface SettingsTabSchema {
72
+ /** Tab label ("Review") */
73
+ label: string;
74
+ /** Ordered list of setting IDs in this tab */
75
+ settingIds: string[];
76
+ }
77
+
78
+ /** Full settings schema — defines all settings, tabs, and file paths. */
79
+ export interface SettingsSchema {
80
+ /** All setting definitions */
81
+ settings: SettingSchema[];
82
+ /** Tab layout */
83
+ tabs: SettingsTabSchema[];
84
+ /** Where global settings file lives */
85
+ globalPath: (globalDir?: string) => string;
86
+ /** Where project settings file lives */
87
+ projectPath: (cwd: string) => string;
88
+ }
89
+
90
+ /** Tab definition for UI rendering. */
91
+ export interface TabDefinition {
92
+ label: string;
93
+ settings: SettingItem[];
94
+ }
95
+
96
+ /** A single preset entry: display label paired with its raw typed value and serialized form. */
97
+ export interface PresetItem {
98
+ label: string;
99
+ /** Raw typed value (passed to updateSetting for value-based matching). */
100
+ rawValue: string | number | boolean | null;
101
+ /** Serialized string form (for current-value index matching against currentRawValue). */
102
+ displayValue: string;
103
+ }
104
+
105
+ /** Single setting item in a tab. */
106
+ export interface SettingItem {
107
+ id: string;
108
+ label: string;
109
+ value: string;
110
+ displayValue: string;
111
+ type: SettingType;
112
+ description?: string;
113
+ /** Cohesive preset list (labels + raw values + display strings derived from `presets` pairs). */
114
+ presets?: readonly PresetItem[];
115
+ }
116
+
117
+ // ── Storage levels (which persistence targets a settings handle offers) ─────────
118
+
119
+ /** A persistence target a settings handle / modal may offer. */
120
+ export type StorageLevel = "session" | "project" | "global";
121
+
122
+ /** The full set of levels, in canonical order. */
123
+ export const DEFAULT_STORAGE_LEVELS: readonly StorageLevel[] = ["session", "project", "global"];
124
+
125
+ /** Valid level strings (for normalization / typo rejection). */
126
+ export const VALID_STORAGE_LEVELS: ReadonlySet<StorageLevel> = new Set<StorageLevel>(DEFAULT_STORAGE_LEVELS);
127
+
128
+ /**
129
+ * Normalize a consumer-supplied `storageLevels`: drop unknown/duplicate entries, fall back to
130
+ * the full default set when nothing valid remains. Deduping/validating BEFORE the count rule
131
+ * prevents duplicates or typos from silently flipping single-level → multi-level mode.
132
+ */
133
+ export function normalizeStorageLevels(levels: StorageLevel[] | undefined): StorageLevel[] {
134
+ if (!levels || levels.length === 0) return [...DEFAULT_STORAGE_LEVELS];
135
+ const seen = new Set<StorageLevel>();
136
+ const out: StorageLevel[] = [];
137
+ for (const level of levels) {
138
+ if (VALID_STORAGE_LEVELS.has(level) && !seen.has(level)) {
139
+ seen.add(level);
140
+ out.push(level);
141
+ }
142
+ }
143
+ return out.length > 0 ? out : [...DEFAULT_STORAGE_LEVELS];
144
+ }
@@ -0,0 +1,98 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Dialog coordinator forwarding — drop-in integration with avtc-pi-ui-components DialogCoordinator.
6
+ *
7
+ * Copy this file into your extension's src/snippets/vendored/ directory, then:
8
+ * 1. Call subscribeToDialogCoordinator(pi) in your extension entry point
9
+ * 2. Call withCoordinator(fn) around any blocking ctx.ui.* call
10
+ *
11
+ * If avtc-pi-ui-components is not installed or the coordinator event never fires,
12
+ * all functions pass through (no-op).
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+
17
+ /**
18
+ * Shared VALUE, stored on globalThis so it survives jiti `moduleCache: false` re-imports
19
+ * (which create distinct module instances and would otherwise split a module-level
20
+ * `let` between the setter in subscribeToDialogCoordinator() and the getter in
21
+ * withCoordinator()).
22
+ *
23
+ * `coordinator` is idempotent across subscribers — there is one DialogCoordinator,
24
+ * so one object. Listener unsubs are NOT shared: each subscribeToDialogCoordinator()
25
+ * call owns its own local closure unsubs.
26
+ */
27
+ interface DialogCoordinatorForwardingState {
28
+ coordinator: { enqueueOrShow<T>(fn: () => Promise<T>): Promise<T> } | null;
29
+ }
30
+ const STATE_KEY = "__piDialogCoordinatorForwarding";
31
+ type GlobalWithState = typeof globalThis & { [STATE_KEY]?: DialogCoordinatorForwardingState };
32
+ function _state(): DialogCoordinatorForwardingState {
33
+ const g = globalThis as GlobalWithState;
34
+ if (!g[STATE_KEY]) {
35
+ g[STATE_KEY] = { coordinator: null };
36
+ }
37
+ const state = g[STATE_KEY];
38
+ return state;
39
+ }
40
+
41
+ /**
42
+ * Reset module state — called during test cleanup.
43
+ */
44
+ export function _resetState(): void {
45
+ _state().coordinator = null;
46
+ }
47
+
48
+ /**
49
+ * Subscribe to dialog-coordinator:ready event emitted by avtc-pi-ui-components.
50
+ * Call once per extension entry point.
51
+ *
52
+ * Reload-safe: session_shutdown fires before reload, cleaning all listeners.
53
+ */
54
+ export function subscribeToDialogCoordinator(pi: ExtensionAPI): void {
55
+ if (!pi.events) return; // graceful no-op for incomplete test mocks
56
+
57
+ const unsubs: Array<() => void> = [];
58
+
59
+ // Listen for dialog-coordinator:ready (emitted by avtc-pi-ui-components in session_start)
60
+ unsubs.push(
61
+ pi.events.on("dialog-coordinator:ready", (data: unknown) => {
62
+ const api = data as {
63
+ coordinator?: { enqueueOrShow<T>(fn: () => Promise<T>): Promise<T> };
64
+ };
65
+ if (api?.coordinator && typeof api.coordinator.enqueueOrShow === "function") {
66
+ _state().coordinator = api.coordinator;
67
+ }
68
+ }),
69
+ );
70
+
71
+ // Reset on session shutdown (fires before reload) — clean ONLY this subscriber's
72
+ // EventBus listener (pi.on listeners are torn down by pi), then clear shared value
73
+ // (hygiene; provider re-emits on next session_start).
74
+ pi.on("session_shutdown", () => {
75
+ for (const unsub of unsubs) {
76
+ if (typeof unsub === "function") unsub();
77
+ }
78
+ unsubs.length = 0;
79
+ _state().coordinator = null;
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Wrap an async UI call with dialog coordination.
85
+ * The call is serialized through the shared queue: if another dialog is currently
86
+ * showing, this call waits and is shown only after the active dialog resolves.
87
+ * If no coordinator is installed, the call is executed immediately (no-op).
88
+ *
89
+ * @param fn - Async function that blocks on user input (e.g. ctx.ui.select, ctx.ui.custom)
90
+ * @returns A promise that resolves with whatever `fn` returns.
91
+ */
92
+ export async function withCoordinator<T>(fn: () => Promise<T>): Promise<T> {
93
+ const coordinator = _state().coordinator;
94
+ if (coordinator) {
95
+ return coordinator.enqueueOrShow(fn);
96
+ }
97
+ return fn();
98
+ }
@@ -0,0 +1,150 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Notification forwarding — drop-in integration with pi-notification.
6
+ *
7
+ * Copy this file into your extension's src/snippets/vendored/ directory, then:
8
+ * 1. Call subscribeToNotificationApi(pi) in your extension entry point
9
+ * 2. Call requestAttention(source, detail) before any blocking UI call
10
+ * 3. Call the returned cancel function when unblocked
11
+ *
12
+ * If pi-notification is not installed, all functions are no-ops.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+
17
+ /**
18
+ * Shared VALUES, stored on globalThis so they survive jiti `moduleCache: false` re-imports
19
+ * (which create distinct module instances and would otherwise split module-level `let`
20
+ * variables between the setter in extension.ts and the getter in select-with-note.ts).
21
+ *
22
+ * Only idempotent VALUES live here — there is one provider (so one requestAttention fn)
23
+ * and one active session (so one lastMessage), observed identically by every
24
+ * subscriber. Listener unsubs are NOT shared: each subscribeToNotificationApi() call
25
+ * owns its own local closure unsubs, so multiple subscribers (ui-components,
26
+ * ask-user-question, feature-flow) don't clobber each other's cleanup.
27
+ */
28
+ interface NotificationForwardingState {
29
+ requestAttention: ((source: string, detail?: string) => (() => void) | undefined) | null;
30
+ lastMessage: string;
31
+ }
32
+ const STATE_KEY = "__piNotificationForwarding";
33
+ type GlobalWithState = typeof globalThis & { [STATE_KEY]?: NotificationForwardingState };
34
+ function _state(): NotificationForwardingState {
35
+ const g = globalThis as GlobalWithState;
36
+ if (!g[STATE_KEY]) {
37
+ g[STATE_KEY] = { requestAttention: null, lastMessage: "" };
38
+ }
39
+ const state = g[STATE_KEY];
40
+ return state;
41
+ }
42
+
43
+ /**
44
+ * Extract the last assistant text from a message_end event.
45
+ * Handles string content and array content (filters to text blocks, takes last).
46
+ * Returns empty string if no suitable text found.
47
+ */
48
+ type MessageContentBlock = { type: string; text?: string };
49
+
50
+ export function extractLastAssistantText(event: {
51
+ message?: { role?: string; content?: string | MessageContentBlock[] };
52
+ }): string {
53
+ if (event.message?.role !== "assistant") return "";
54
+
55
+ const content = event.message.content;
56
+ if (typeof content === "string") return content;
57
+ if (Array.isArray(content)) {
58
+ const textBlocks = content.filter((b): b is MessageContentBlock & { type: "text" } => b.type === "text");
59
+ const last = textBlocks[textBlocks.length - 1];
60
+ if (last) {
61
+ return last.text ?? "";
62
+ }
63
+ }
64
+ return "";
65
+ }
66
+
67
+ /**
68
+ * Get the last captured assistant message text.
69
+ * Returns empty string if no message has been captured yet.
70
+ */
71
+ export function getLastMessage(): string {
72
+ return _state().lastMessage;
73
+ }
74
+
75
+ /**
76
+ * Reset module state — called on session shutdown and during test cleanup.
77
+ * Clears shared values; listener cleanup is each subscriber's own responsibility
78
+ * (via its session_shutdown handler).
79
+ */
80
+ export function _resetNotificationState(): void {
81
+ const s = _state();
82
+ s.lastMessage = "";
83
+ s.requestAttention = null;
84
+ }
85
+
86
+ /**
87
+ * Subscribe to pi-notification:ready event and message_end events.
88
+ * Call once per extension entry point (all will set the same shared reference).
89
+ *
90
+ * Reload-safe: session_shutdown fires before reload, cleaning all listeners.
91
+ */
92
+ export function subscribeToNotificationApi(pi: ExtensionAPI): void {
93
+ const unsubs: Array<() => void> = [];
94
+
95
+ const unsub = pi.events.on("pi-notification:ready", (data: unknown) => {
96
+ const api = data as {
97
+ requestAttention?: (source: string, detail?: string) => (() => void) | undefined;
98
+ };
99
+ if (typeof api.requestAttention === "function" && api.requestAttention) {
100
+ _state().requestAttention = api.requestAttention;
101
+ }
102
+ });
103
+ unsubs.push(unsub);
104
+
105
+ // Capture last assistant message for notification context.
106
+ // Note: pi.on (extension lifecycle events) returns void — these listeners are torn down
107
+ // by pi when it discards the extension runner on reload/session-end, so no manual unsub.
108
+ if (typeof pi.on === "function") {
109
+ pi.on("message_end", (event: { message?: { role?: string; content?: string | MessageContentBlock[] } }) => {
110
+ const text = extractLastAssistantText(event);
111
+ if (text) _state().lastMessage = text;
112
+ });
113
+ }
114
+
115
+ // Reset on session shutdown (fires before reload) — clean ONLY this subscriber's
116
+ // EventBus listener (pi.on listeners are torn down by pi), then clear shared values
117
+ // (hygiene; provider re-emits on next session_start).
118
+ pi.on("session_shutdown", () => {
119
+ for (const unsub of unsubs) unsub();
120
+ unsubs.length = 0;
121
+ const s = _state();
122
+ s.lastMessage = "";
123
+ s.requestAttention = null;
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Request attention before blocking on user input.
129
+ * Returns a cancel function, or undefined if pi-notification is not installed.
130
+ */
131
+ export function requestAttention(source: string, detail: string): (() => void) | undefined {
132
+ return _state().requestAttention?.(source, detail);
133
+ }
134
+
135
+ /**
136
+ * Wrap an async UI call with requestAttention/cancelAttention.
137
+ * If pi-notification is not installed, just calls the function directly.
138
+ *
139
+ * @param source - Who is requesting attention (e.g. "permission", "ask_user_question")
140
+ * @param detail - Optional detail (e.g. tool name, question summary)
141
+ * @param fn - Async function that blocks on user input
142
+ */
143
+ export async function withAttention<T>(source: string, detail: string, fn: () => Promise<T>): Promise<T> {
144
+ const cancelAttention = requestAttention(source, detail);
145
+ try {
146
+ return await fn();
147
+ } finally {
148
+ cancelAttention?.();
149
+ }
150
+ }