@sherif-fanous/pi-presets-plus 0.1.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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +74 -0
- package/src/activation/active-state.ts +25 -0
- package/src/activation/apply.ts +236 -0
- package/src/activation/baseline.ts +32 -0
- package/src/activation/clear.ts +434 -0
- package/src/activation/dirty.ts +69 -0
- package/src/activation/drift-handlers.ts +71 -0
- package/src/activation/drift.ts +77 -0
- package/src/activation/same-set.ts +32 -0
- package/src/activation/state-matches.ts +29 -0
- package/src/activation/thinking.ts +54 -0
- package/src/commands/presets/clear.ts +18 -0
- package/src/commands/presets/index.ts +9 -0
- package/src/commands/presets/notify.ts +22 -0
- package/src/commands/presets/reload.ts +28 -0
- package/src/commands/presets/router.ts +139 -0
- package/src/commands/presets/status.ts +262 -0
- package/src/flag.ts +88 -0
- package/src/hotkey-conflicts.ts +136 -0
- package/src/hotkey-reload-baseline.ts +112 -0
- package/src/hotkeys.ts +104 -0
- package/src/index.ts +171 -0
- package/src/messages.ts +34 -0
- package/src/store/api.ts +262 -0
- package/src/store/load.ts +175 -0
- package/src/store/merge.ts +69 -0
- package/src/store/paths.ts +38 -0
- package/src/store/save.ts +75 -0
- package/src/store/validate.ts +195 -0
- package/src/types.ts +169 -0
- package/src/ui/confirm.ts +126 -0
- package/src/ui/editor.ts +1617 -0
- package/src/ui/filter.ts +79 -0
- package/src/ui/frame.ts +109 -0
- package/src/ui/hotkey-input.ts +242 -0
- package/src/ui/info-dialog.ts +118 -0
- package/src/ui/labels.ts +51 -0
- package/src/ui/picker-state.ts +151 -0
- package/src/ui/picker.ts +982 -0
- package/src/ui/reload-prompt.ts +59 -0
- package/src/ui/status.ts +55 -0
- package/src/ui/widgets.ts +274 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reload confirmation helper shared by commit-time preset mutations.
|
|
3
|
+
*
|
|
4
|
+
* Owns the user-facing reload prompt and guarded `ctx.reload()` invocation;
|
|
5
|
+
* it does NOT own deciding whether a particular preset mutation needs a
|
|
6
|
+
* reload.
|
|
7
|
+
*/
|
|
8
|
+
import { openConfirm } from "./confirm.js";
|
|
9
|
+
import { RELOAD_PROMPT_TITLE } from "./labels.js";
|
|
10
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
const RELOAD_PROMPT_BODY =
|
|
13
|
+
"Hotkey changes take effect after a reload. Reload now?";
|
|
14
|
+
|
|
15
|
+
interface ReloadContext {
|
|
16
|
+
readonly reload?: () => Promise<void>;
|
|
17
|
+
readonly ui: ExtensionCommandContext["ui"];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Ask whether Pi should reload now, returning false when reload is unavailable. */
|
|
21
|
+
export async function confirmReload(ctx: ReloadContext): Promise<boolean> {
|
|
22
|
+
if (typeof ctx.reload !== "function") return false;
|
|
23
|
+
|
|
24
|
+
return openConfirm(ctx, RELOAD_PROMPT_TITLE, RELOAD_PROMPT_BODY);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reload Pi after giving custom overlays a turn to resolve and unmount.
|
|
29
|
+
*
|
|
30
|
+
* Reload failures are reported to the user instead of escaping the calling
|
|
31
|
+
* editor or picker flow. Callers should resolve their overlay before invoking
|
|
32
|
+
* this helper; otherwise stale TUI components may survive the extension reload.
|
|
33
|
+
*/
|
|
34
|
+
export function reloadAfterOverlayClose(ctx: ReloadContext): void {
|
|
35
|
+
const { reload } = ctx;
|
|
36
|
+
|
|
37
|
+
if (typeof reload !== "function") return;
|
|
38
|
+
|
|
39
|
+
setTimeout(() => {
|
|
40
|
+
void reloadPi({ reload, ui: ctx.ui });
|
|
41
|
+
}, 0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatError(error: unknown): string {
|
|
45
|
+
if (error instanceof Error) return error.message;
|
|
46
|
+
if (typeof error === "string") return error;
|
|
47
|
+
|
|
48
|
+
return "unknown error";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function reloadPi(
|
|
52
|
+
ctx: Required<Pick<ReloadContext, "reload" | "ui">>,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
try {
|
|
55
|
+
await ctx.reload();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
ctx.ui.notify(`Failed to reload Pi: ${formatError(error)}.`, "error");
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/ui/status.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-bar rendering for active presets.
|
|
3
|
+
*
|
|
4
|
+
* Owns the compact `presets-plus` footer status entry; it does NOT
|
|
5
|
+
* compute drift or mutate active state.
|
|
6
|
+
*/
|
|
7
|
+
import type { ActivePresetState, LoadedPreset } from "../types.js";
|
|
8
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
const STATUS_KEY = "presets-plus";
|
|
11
|
+
|
|
12
|
+
/** Minimal context surface needed to update footer status. */
|
|
13
|
+
type StatusContext = Pick<ExtensionContext, "ui">;
|
|
14
|
+
|
|
15
|
+
/** Render or clear the active-preset status badge. */
|
|
16
|
+
export function updateStatus(
|
|
17
|
+
ctx: StatusContext,
|
|
18
|
+
active: ActivePresetState | undefined,
|
|
19
|
+
lookup: (
|
|
20
|
+
name: string,
|
|
21
|
+
scope: ActivePresetState["scope"],
|
|
22
|
+
) => LoadedPreset | undefined,
|
|
23
|
+
): void {
|
|
24
|
+
if (!active) {
|
|
25
|
+
ctx.ui.setStatus(STATUS_KEY, dim(ctx, "Preset: none"));
|
|
26
|
+
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const preset = lookup(active.name, active.scope);
|
|
31
|
+
|
|
32
|
+
if (!preset) {
|
|
33
|
+
ctx.ui.setStatus(STATUS_KEY, dim(ctx, "Preset: none"));
|
|
34
|
+
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const label = dim(ctx, `Preset: ${preset.name}`);
|
|
39
|
+
|
|
40
|
+
if (!active.dirty) {
|
|
41
|
+
ctx.ui.setStatus(STATUS_KEY, label);
|
|
42
|
+
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ctx.ui.setStatus(STATUS_KEY, `${label}${warning(ctx, "!")}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dim(ctx: StatusContext, text: string): string {
|
|
50
|
+
return ctx.ui.theme?.fg("dim", text) ?? text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function warning(ctx: StatusContext, text: string): string {
|
|
54
|
+
return ctx.ui.theme?.fg("warning", text) ?? text;
|
|
55
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable preset-picker widget primitives.
|
|
3
|
+
*
|
|
4
|
+
* Owns readable key/value preset card rendering; it does NOT own picker
|
|
5
|
+
* state, keyboard handling, or activation.
|
|
6
|
+
*/
|
|
7
|
+
import type { LoadedPreset } from "../types.js";
|
|
8
|
+
import {
|
|
9
|
+
MODEL_LABEL,
|
|
10
|
+
SCOPE_LABEL,
|
|
11
|
+
STATUS_LABEL,
|
|
12
|
+
THINKING_LABEL,
|
|
13
|
+
TOOLS_LABEL,
|
|
14
|
+
} from "./labels.js";
|
|
15
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import { truncateToWidth, type Component } from "@mariozechner/pi-tui";
|
|
17
|
+
|
|
18
|
+
export interface PresetCardOptions {
|
|
19
|
+
active: boolean;
|
|
20
|
+
dirty?: boolean;
|
|
21
|
+
driftReasons?: readonly string[];
|
|
22
|
+
inheritedTools?: readonly string[];
|
|
23
|
+
selected: boolean;
|
|
24
|
+
showShadowed?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Minimum theme surface needed by the preset card and its formatters.
|
|
29
|
+
*
|
|
30
|
+
* Restricting to `fg` + `bold` (matching the `Styler` pattern in
|
|
31
|
+
* `activation/clear.ts`) lets tests pass an honest stub without an
|
|
32
|
+
* `as unknown as Theme` cast and makes future Theme additions
|
|
33
|
+
* visible at the call sites that actually need them.
|
|
34
|
+
*/
|
|
35
|
+
type CardTheme = Pick<Theme, "fg" | "bold">;
|
|
36
|
+
|
|
37
|
+
type ThinkingColor = Parameters<Theme["fg"]>[0];
|
|
38
|
+
|
|
39
|
+
const FIELD_LABEL_WIDTH = Math.max(
|
|
40
|
+
"Shadowing:".length,
|
|
41
|
+
`${THINKING_LABEL}:`.length,
|
|
42
|
+
);
|
|
43
|
+
const PROMPT_PREVIEW_WIDTH = 60;
|
|
44
|
+
|
|
45
|
+
class PresetCardComponent implements Component {
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly loadedPreset: LoadedPreset,
|
|
48
|
+
private readonly theme: CardTheme,
|
|
49
|
+
private readonly options: PresetCardOptions,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
invalidate(): void {}
|
|
53
|
+
|
|
54
|
+
render(width: number): string[] {
|
|
55
|
+
const titlePrefix = this.options.selected
|
|
56
|
+
? this.theme.fg("accent", "▌ ")
|
|
57
|
+
: " ";
|
|
58
|
+
const dot = this.theme.fg(
|
|
59
|
+
this.options.active ? "success" : "dim",
|
|
60
|
+
formatStatusDot(this.options.active),
|
|
61
|
+
);
|
|
62
|
+
const displayName = this.options.active
|
|
63
|
+
? this.theme.fg("accent", this.theme.bold(this.loadedPreset.name))
|
|
64
|
+
: this.theme.fg("text", this.loadedPreset.name);
|
|
65
|
+
const lines = [`${titlePrefix}${dot} ${displayName}`];
|
|
66
|
+
|
|
67
|
+
lines.push(
|
|
68
|
+
this.renderField(`${SCOPE_LABEL}:`, formatScopeValue(this.loadedPreset)),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
lines.push(
|
|
72
|
+
this.renderField(
|
|
73
|
+
`${MODEL_LABEL}:`,
|
|
74
|
+
`${this.loadedPreset.provider} / ${this.loadedPreset.model}`,
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
lines.push(
|
|
79
|
+
this.renderField(
|
|
80
|
+
`${THINKING_LABEL}:`,
|
|
81
|
+
this.theme.fg(
|
|
82
|
+
thinkingColor(this.loadedPreset.thinkingLevel ?? "off"),
|
|
83
|
+
formatThinkingLevel(this.loadedPreset.thinkingLevel ?? "off"),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
lines.push(
|
|
89
|
+
this.renderField(
|
|
90
|
+
`${TOOLS_LABEL}:`,
|
|
91
|
+
formatToolsSummary(
|
|
92
|
+
this.loadedPreset.tools,
|
|
93
|
+
this.options.inheritedTools ?? [],
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const promptPreview = formatInstructionsPreview(
|
|
99
|
+
this.loadedPreset.instructions,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (promptPreview.length > 0) {
|
|
103
|
+
lines.push(this.renderField("Prompt:", promptPreview));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (this.loadedPreset.clampWarning === true) {
|
|
107
|
+
lines.push(
|
|
108
|
+
this.renderField(
|
|
109
|
+
`${STATUS_LABEL}:`,
|
|
110
|
+
this.theme.fg("warning", "⚠ Thinking will be clamped."),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (this.loadedPreset.hotkeyConflict === true) {
|
|
116
|
+
lines.push(
|
|
117
|
+
this.renderField(
|
|
118
|
+
`${STATUS_LABEL}:`,
|
|
119
|
+
this.theme.fg("warning", "⚠ Hotkey conflict."),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.loadedPreset.hotkeyShadowsBuiltin === true) {
|
|
125
|
+
lines.push(
|
|
126
|
+
this.renderField(
|
|
127
|
+
`${STATUS_LABEL}:`,
|
|
128
|
+
this.theme.fg("warning", "⚠ Hotkey shadows a Pi built-in."),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const availabilityStatus = formatAvailabilityStatus(this.loadedPreset);
|
|
134
|
+
|
|
135
|
+
if (availabilityStatus.length > 0) {
|
|
136
|
+
lines.push(
|
|
137
|
+
this.renderField(
|
|
138
|
+
`${STATUS_LABEL}:`,
|
|
139
|
+
this.theme.fg("warning", availabilityStatus),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
this.options.active &&
|
|
146
|
+
this.options.dirty &&
|
|
147
|
+
this.options.driftReasons &&
|
|
148
|
+
this.options.driftReasons.length > 0
|
|
149
|
+
) {
|
|
150
|
+
lines.push(
|
|
151
|
+
this.renderField(
|
|
152
|
+
"Drift:",
|
|
153
|
+
this.theme.fg(
|
|
154
|
+
"warning",
|
|
155
|
+
`⚠ Dirty — ${this.options.driftReasons.join(", ")} differ`,
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (this.loadedPreset.shadowed && this.options.showShadowed !== false) {
|
|
162
|
+
lines.push(
|
|
163
|
+
this.renderField(
|
|
164
|
+
"Shadowing:",
|
|
165
|
+
this.theme.fg("dim", "Overridden by project preset"),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return lines.map((line) => truncateToWidth(line, width, "…"));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private renderField(label: string, value: string): string {
|
|
174
|
+
const padding = " ".repeat(Math.max(0, FIELD_LABEL_WIDTH - label.length));
|
|
175
|
+
|
|
176
|
+
return ` ${this.theme.fg("muted", label)}${padding} ${value}`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function formatAvailabilityStatus(loadedPreset: LoadedPreset): string {
|
|
181
|
+
switch (loadedPreset.unavailable) {
|
|
182
|
+
case "no-key":
|
|
183
|
+
return "⚠ This preset's provider has no API key configured.";
|
|
184
|
+
case "no-model":
|
|
185
|
+
return "⚠ This preset's model is no longer available.";
|
|
186
|
+
case undefined:
|
|
187
|
+
return "";
|
|
188
|
+
default:
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function formatInstructionsPreview(
|
|
194
|
+
instructions: string | undefined,
|
|
195
|
+
): string {
|
|
196
|
+
if (!instructions) return "";
|
|
197
|
+
|
|
198
|
+
const singleLine = instructions.replaceAll(/\s+/g, " ").trim();
|
|
199
|
+
|
|
200
|
+
if (singleLine.length <= PROMPT_PREVIEW_WIDTH) return singleLine;
|
|
201
|
+
|
|
202
|
+
return `${singleLine.slice(0, PROMPT_PREVIEW_WIDTH - 1).trimEnd()}…`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function formatScopeValue(loadedPreset: LoadedPreset): string {
|
|
206
|
+
return loadedPreset.scope === "project" ? "Project" : "User";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function formatStatusDot(active: boolean): string {
|
|
210
|
+
return active ? "●" : " ";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function formatThinkingLevel(
|
|
214
|
+
level: NonNullable<LoadedPreset["thinkingLevel"]>,
|
|
215
|
+
): string {
|
|
216
|
+
switch (level) {
|
|
217
|
+
case "minimal":
|
|
218
|
+
return "Minimal";
|
|
219
|
+
case "low":
|
|
220
|
+
return "Low";
|
|
221
|
+
case "medium":
|
|
222
|
+
return "Medium";
|
|
223
|
+
case "high":
|
|
224
|
+
return "High";
|
|
225
|
+
case "xhigh":
|
|
226
|
+
return "X-High";
|
|
227
|
+
case "off":
|
|
228
|
+
return "Off";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function formatToolsSummary(
|
|
233
|
+
tools: readonly string[] | undefined,
|
|
234
|
+
inheritedTools: readonly string[] = [],
|
|
235
|
+
): string {
|
|
236
|
+
if (tools && tools.length > 0) return `Preset: ${tools.join(", ")}`;
|
|
237
|
+
if (inheritedTools.length === 0) return "Session";
|
|
238
|
+
|
|
239
|
+
return `Session: ${inheritedTools.join(", ")}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Multi-line component for a single loaded preset.
|
|
244
|
+
*
|
|
245
|
+
* The card is intentionally stateless: callers pass active/selected flags on
|
|
246
|
+
* construction and rebuild cards when state changes. This keeps rendering
|
|
247
|
+
* deterministic and easy for future editor dialogs to share.
|
|
248
|
+
*/
|
|
249
|
+
export function presetCard(
|
|
250
|
+
loadedPreset: LoadedPreset,
|
|
251
|
+
theme: CardTheme,
|
|
252
|
+
options: PresetCardOptions,
|
|
253
|
+
): Component {
|
|
254
|
+
return new PresetCardComponent(loadedPreset, theme, options);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function thinkingColor(
|
|
258
|
+
level: NonNullable<LoadedPreset["thinkingLevel"]>,
|
|
259
|
+
): ThinkingColor {
|
|
260
|
+
switch (level) {
|
|
261
|
+
case "minimal":
|
|
262
|
+
return "thinkingMinimal";
|
|
263
|
+
case "low":
|
|
264
|
+
return "thinkingLow";
|
|
265
|
+
case "medium":
|
|
266
|
+
return "thinkingMedium";
|
|
267
|
+
case "high":
|
|
268
|
+
return "thinkingHigh";
|
|
269
|
+
case "xhigh":
|
|
270
|
+
return "thinkingXhigh";
|
|
271
|
+
case "off":
|
|
272
|
+
return "thinkingOff";
|
|
273
|
+
}
|
|
274
|
+
}
|