@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,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation primitives for the preset storage layer.
|
|
3
|
+
*
|
|
4
|
+
* Owns shape validation of individual presets, duplicate-name detection
|
|
5
|
+
* within a list, and runtime availability checks against pi's model
|
|
6
|
+
* registry. None of these helpers throw or perform file-system I/O.
|
|
7
|
+
*/
|
|
8
|
+
import type { Preset, ThinkingLevel } from "../types.js";
|
|
9
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
/** Result of a single-preset shape check. */
|
|
12
|
+
interface ValidationResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
/** Human-readable reason the preset failed validation; absent on success. */
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Allowed values for `Preset.thinkingLevel`. */
|
|
19
|
+
const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
20
|
+
"off",
|
|
21
|
+
"minimal",
|
|
22
|
+
"low",
|
|
23
|
+
"medium",
|
|
24
|
+
"high",
|
|
25
|
+
"xhigh",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute the availability of a preset against the live model registry.
|
|
30
|
+
*
|
|
31
|
+
* Returns:
|
|
32
|
+
* - `"no-model"` the preset's `provider`/`model` is not registered
|
|
33
|
+
* - `"no-key"` the model is registered but its provider has no API key
|
|
34
|
+
* - `undefined` the preset is fully available
|
|
35
|
+
*
|
|
36
|
+
* Does not perform any network I/O: the API key check uses
|
|
37
|
+
* `hasConfiguredAuth` (synchronous, fast) rather than refreshing OAuth
|
|
38
|
+
* tokens. Activation-time code paths (later change) re-check with the
|
|
39
|
+
* async resolver.
|
|
40
|
+
*/
|
|
41
|
+
export function computeAvailability(
|
|
42
|
+
preset: Pick<Preset, "provider" | "model">,
|
|
43
|
+
ctx: Pick<ExtensionContext, "modelRegistry">,
|
|
44
|
+
): "no-model" | "no-key" | undefined {
|
|
45
|
+
const model = ctx.modelRegistry.find(preset.provider, preset.model);
|
|
46
|
+
|
|
47
|
+
if (!model) return "no-model";
|
|
48
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) return "no-key";
|
|
49
|
+
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Return whether activation will clamp a preset's thinking level to off. */
|
|
54
|
+
export function computeClampWarning(
|
|
55
|
+
preset: Pick<Preset, "provider" | "model" | "thinkingLevel">,
|
|
56
|
+
ctx: Pick<ExtensionContext, "modelRegistry">,
|
|
57
|
+
): boolean {
|
|
58
|
+
if (!preset.thinkingLevel || preset.thinkingLevel === "off") return false;
|
|
59
|
+
|
|
60
|
+
const model = ctx.modelRegistry.find(preset.provider, preset.model);
|
|
61
|
+
|
|
62
|
+
if (!model) return false;
|
|
63
|
+
|
|
64
|
+
return model.reasoning === false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Find duplicate `name` entries in a preset array, preserving the index of
|
|
69
|
+
* each duplicate (i.e. the second and subsequent occurrences) so the
|
|
70
|
+
* loader can skip-and-warn naming the offenders.
|
|
71
|
+
*
|
|
72
|
+
* Pure: does not mutate the input array.
|
|
73
|
+
*/
|
|
74
|
+
export function findDuplicatePresetNames(
|
|
75
|
+
presets: readonly Preset[],
|
|
76
|
+
): { name: string; index: number }[] {
|
|
77
|
+
const seenPresetNames = new Set<string>();
|
|
78
|
+
const duplicatePresetNames: { name: string; index: number }[] = [];
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < presets.length; i++) {
|
|
81
|
+
const preset = presets[i];
|
|
82
|
+
|
|
83
|
+
if (!preset) continue;
|
|
84
|
+
|
|
85
|
+
const name = preset.name;
|
|
86
|
+
|
|
87
|
+
if (seenPresetNames.has(name)) {
|
|
88
|
+
duplicatePresetNames.push({ name, index: i });
|
|
89
|
+
} else {
|
|
90
|
+
seenPresetNames.add(name);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return duplicatePresetNames;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate the shape of a single preset.
|
|
99
|
+
*
|
|
100
|
+
* Required fields (per the storage spec):
|
|
101
|
+
* - `name` non-empty string
|
|
102
|
+
* - `provider` non-empty string
|
|
103
|
+
* - `model` non-empty string
|
|
104
|
+
*
|
|
105
|
+
* Optional fields are checked when present:
|
|
106
|
+
* - `thinkingLevel` must be in the `ThinkingLevel` enum
|
|
107
|
+
* - `tools` must be an array of strings
|
|
108
|
+
* - `instructions` must be a string
|
|
109
|
+
* - `hotkey` must be a string
|
|
110
|
+
* - `order` must be a finite number
|
|
111
|
+
*
|
|
112
|
+
* Unknown fields are accepted (forward-compat); the loader's serializer
|
|
113
|
+
* only round-trips the typed shape but extra fields are not flagged.
|
|
114
|
+
*/
|
|
115
|
+
export function validatePresetShape(
|
|
116
|
+
candidatePreset: unknown,
|
|
117
|
+
): ValidationResult {
|
|
118
|
+
if (
|
|
119
|
+
typeof candidatePreset !== "object" ||
|
|
120
|
+
candidatePreset === null ||
|
|
121
|
+
Array.isArray(candidatePreset)
|
|
122
|
+
) {
|
|
123
|
+
return { ok: false, reason: "Preset is not an object." };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const obj = candidatePreset as Record<string, unknown>;
|
|
127
|
+
const requireString = (
|
|
128
|
+
field: "name" | "provider" | "model",
|
|
129
|
+
): ValidationResult | undefined => {
|
|
130
|
+
const value = obj[field];
|
|
131
|
+
|
|
132
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: `Missing or empty required field "${field}".`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return undefined;
|
|
140
|
+
};
|
|
141
|
+
const nameError = requireString("name");
|
|
142
|
+
|
|
143
|
+
if (nameError) return nameError;
|
|
144
|
+
|
|
145
|
+
const providerError = requireString("provider");
|
|
146
|
+
|
|
147
|
+
if (providerError) return providerError;
|
|
148
|
+
|
|
149
|
+
const modelError = requireString("model");
|
|
150
|
+
|
|
151
|
+
if (modelError) return modelError;
|
|
152
|
+
|
|
153
|
+
if (obj.thinkingLevel !== undefined) {
|
|
154
|
+
if (
|
|
155
|
+
typeof obj.thinkingLevel !== "string" ||
|
|
156
|
+
!THINKING_LEVELS.includes(obj.thinkingLevel as ThinkingLevel)
|
|
157
|
+
) {
|
|
158
|
+
const label =
|
|
159
|
+
typeof obj.thinkingLevel === "string"
|
|
160
|
+
? JSON.stringify(obj.thinkingLevel)
|
|
161
|
+
: typeof obj.thinkingLevel;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
reason: `Invalid thinkingLevel ${label} (expected one of ${THINKING_LEVELS.join(", ")}).`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (obj.tools !== undefined) {
|
|
171
|
+
if (
|
|
172
|
+
!Array.isArray(obj.tools) ||
|
|
173
|
+
obj.tools.some((tool) => typeof tool !== "string")
|
|
174
|
+
) {
|
|
175
|
+
return { ok: false, reason: `"tools" must be an array of strings.` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (obj.instructions !== undefined && typeof obj.instructions !== "string") {
|
|
180
|
+
return { ok: false, reason: `"instructions" must be a string.` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (obj.hotkey !== undefined && typeof obj.hotkey !== "string") {
|
|
184
|
+
return { ok: false, reason: `"hotkey" must be a string.` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
obj.order !== undefined &&
|
|
189
|
+
(typeof obj.order !== "number" || !Number.isFinite(obj.order))
|
|
190
|
+
) {
|
|
191
|
+
return { ok: false, reason: `"order" must be a finite number.` };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { ok: true };
|
|
195
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared type definitions for pi-presets-plus.
|
|
3
|
+
*
|
|
4
|
+
* Owns the persistent preset shapes (`Preset`, `PresetsFile`), scope and
|
|
5
|
+
* loader output types (`PresetScope`, `LoadedPreset`), the activation
|
|
6
|
+
* state shape, and a local `ThinkingLevel` that extends `pi-ai`'s level
|
|
7
|
+
* set with the explicit `"off"` value used by pi.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A preset enriched with merge/availability metadata.
|
|
12
|
+
*
|
|
13
|
+
* Returned by the storage merge step (`mergeScopes`). `shadowed` and
|
|
14
|
+
* `unavailable` are computed at load time and may change across reloads;
|
|
15
|
+
* callers must not assume they survive a `ctx.reload()`.
|
|
16
|
+
*/
|
|
17
|
+
export interface LoadedPreset extends Preset {
|
|
18
|
+
/** Origin file's scope; assigned by the loader. */
|
|
19
|
+
scope: PresetScope;
|
|
20
|
+
/**
|
|
21
|
+
* `true` for a global preset whose name is also defined in the project
|
|
22
|
+
* file (the project entry wins at activation time).
|
|
23
|
+
*/
|
|
24
|
+
shadowed?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Reason the preset cannot be activated, computed at load time:
|
|
27
|
+
* - `"no-model"` — model id not registered for the named provider
|
|
28
|
+
* - `"no-key"` — model is registered but its provider has no API key
|
|
29
|
+
*
|
|
30
|
+
* Undefined when the preset is fully available.
|
|
31
|
+
*/
|
|
32
|
+
unavailable?: "no-key" | "no-model";
|
|
33
|
+
/**
|
|
34
|
+
* True when the preset requests extended thinking for a model that will
|
|
35
|
+
* clamp it to off at activation time. Computed in memory; never persisted.
|
|
36
|
+
*/
|
|
37
|
+
clampWarning?: true;
|
|
38
|
+
/** True when another preset claimed this preset's hotkey first. */
|
|
39
|
+
hotkeyConflict?: true | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* True when the parsed hotkey matches a Pi built-in keybinding. Computed by
|
|
42
|
+
* `annotateAndAnalyzeHotkeys` alongside `hotkeyConflict` at load time so
|
|
43
|
+
* consumers can surface the derived shadowing state.
|
|
44
|
+
*/
|
|
45
|
+
hotkeyShadowsBuiltin?: true | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A preset definition as it appears in either scope's JSON file.
|
|
50
|
+
*
|
|
51
|
+
* Required fields: `name`, `provider`, `model`. All other fields are
|
|
52
|
+
* optional and accepted by the loader unchanged; behavior that consumes
|
|
53
|
+
* them (instructions injection, hotkey binding, ordering) lands in later
|
|
54
|
+
* changes. Storage validates the shape and round-trips unknown-but-typed
|
|
55
|
+
* fields verbatim.
|
|
56
|
+
*/
|
|
57
|
+
export interface Preset {
|
|
58
|
+
/** Unique within a single file; merge-time shadowing is by name. */
|
|
59
|
+
name: string;
|
|
60
|
+
/** Provider id (e.g. `"anthropic"`, `"openai"`). */
|
|
61
|
+
provider: string;
|
|
62
|
+
/** Model id within `provider` (e.g. `"claude-opus-4.5"`). */
|
|
63
|
+
model: string;
|
|
64
|
+
/** Reasoning level. Defaults to `"off"` at apply time (later change). */
|
|
65
|
+
thinkingLevel?: ThinkingLevel;
|
|
66
|
+
/** Active tools at apply time. Omit / empty = session tools pass through unchanged. */
|
|
67
|
+
tools?: string[];
|
|
68
|
+
/** Free-form text appended to the system prompt at apply time. */
|
|
69
|
+
instructions?: string;
|
|
70
|
+
/** Hotkey id; honored by a later change. */
|
|
71
|
+
hotkey?: string;
|
|
72
|
+
/** User-controlled cycle order; default = file order. */
|
|
73
|
+
order?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* In-memory snapshot of the preset fields drift detection compares against.
|
|
78
|
+
*
|
|
79
|
+
* Cached on `ActivePresetState` at apply / restore time so per-turn drift
|
|
80
|
+
* detection never has to re-read the on-disk preset files. Refreshed on
|
|
81
|
+
* apply, on session restore, and on `/presets reload` (via re-apply) — never
|
|
82
|
+
* on `turn_start` or `model_select`.
|
|
83
|
+
*/
|
|
84
|
+
export interface PresetDriftSnapshot {
|
|
85
|
+
provider: string;
|
|
86
|
+
model: string;
|
|
87
|
+
thinkingLevel?: ThinkingLevel;
|
|
88
|
+
tools?: readonly string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Baseline Pi state captured before a preset overlay starts. */
|
|
92
|
+
export interface PresetOverlayBaseline {
|
|
93
|
+
model: { provider: string; id: string } | null;
|
|
94
|
+
thinkingLevel: ThinkingLevel;
|
|
95
|
+
tools: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* On-disk JSON shape for a single preset file (either scope).
|
|
100
|
+
*
|
|
101
|
+
* `version: 1` is the current schema version. Files declaring a different
|
|
102
|
+
* version are treated as empty + warned by the loader (and never rewritten),
|
|
103
|
+
* leaving room for forward-compatible schema evolution.
|
|
104
|
+
*/
|
|
105
|
+
export interface PresetsFile {
|
|
106
|
+
version: 1;
|
|
107
|
+
presets: Preset[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** In-memory active-preset state for change `add-preset-activation`. */
|
|
111
|
+
export type ActivePresetState =
|
|
112
|
+
| {
|
|
113
|
+
name: string;
|
|
114
|
+
scope: PresetScope;
|
|
115
|
+
restore: {
|
|
116
|
+
kind: "baseline";
|
|
117
|
+
baseline: PresetOverlayBaseline;
|
|
118
|
+
lastApplied: LastAppliedPresetEffects;
|
|
119
|
+
owned: PresetOverlayOwnership;
|
|
120
|
+
applyCount: number;
|
|
121
|
+
};
|
|
122
|
+
dirty: boolean;
|
|
123
|
+
declared: PresetDriftSnapshot;
|
|
124
|
+
}
|
|
125
|
+
| {
|
|
126
|
+
name: string;
|
|
127
|
+
scope: PresetScope;
|
|
128
|
+
restore: { kind: "unknown" };
|
|
129
|
+
dirty: boolean;
|
|
130
|
+
declared: PresetDriftSnapshot;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Origin scope for a loaded preset.
|
|
135
|
+
*
|
|
136
|
+
* - `"user"` — the global file under `<agent-dir>/presets-plus/presets.json`
|
|
137
|
+
* - `"project"` — the per-cwd file under `<cwd>/.pi/presets-plus/presets.json`
|
|
138
|
+
*/
|
|
139
|
+
export type PresetScope = "user" | "project";
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Reasoning level recorded on a preset.
|
|
143
|
+
*
|
|
144
|
+
* Mirrors pi-coding-agent's `getThinkingLevel()` / `setThinkingLevel()` API,
|
|
145
|
+
* which extends `pi-ai`'s `ThinkingLevel` with the explicit `"off"` value.
|
|
146
|
+
* Storage accepts the literal set verbatim; per-model clamping happens at
|
|
147
|
+
* activation time in a later change.
|
|
148
|
+
*/
|
|
149
|
+
export type ThinkingLevel =
|
|
150
|
+
| "off"
|
|
151
|
+
| "minimal"
|
|
152
|
+
| "low"
|
|
153
|
+
| "medium"
|
|
154
|
+
| "high"
|
|
155
|
+
| "xhigh";
|
|
156
|
+
|
|
157
|
+
/** Last values written by presets-plus inside the active overlay. */
|
|
158
|
+
interface LastAppliedPresetEffects {
|
|
159
|
+
model: { provider: string; id: string };
|
|
160
|
+
thinkingLevel: ThinkingLevel;
|
|
161
|
+
tools?: string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Tracks which Pi channels are owned by the active preset overlay. */
|
|
165
|
+
interface PresetOverlayOwnership {
|
|
166
|
+
model: true;
|
|
167
|
+
thinkingLevel: true;
|
|
168
|
+
tools: boolean;
|
|
169
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small custom confirmation overlay shared by preset TUI surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Owns yes/no keyboard handling for extension-local confirmations; it does
|
|
5
|
+
* NOT own the action being confirmed or any persistence side effects.
|
|
6
|
+
*/
|
|
7
|
+
import { centerText, renderDialogFrame, wrapBody } from "./frame.js";
|
|
8
|
+
import type {
|
|
9
|
+
ExtensionCommandContext,
|
|
10
|
+
Theme,
|
|
11
|
+
} from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
Key,
|
|
14
|
+
matchesKey,
|
|
15
|
+
type Component,
|
|
16
|
+
type Focusable,
|
|
17
|
+
} from "@mariozechner/pi-tui";
|
|
18
|
+
|
|
19
|
+
class ConfirmComponent implements Component, Focusable {
|
|
20
|
+
private selected: "no" | "yes" = "no";
|
|
21
|
+
private resolved = false;
|
|
22
|
+
private _focused = false;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly title: string,
|
|
26
|
+
private readonly message: string,
|
|
27
|
+
private readonly theme: Theme,
|
|
28
|
+
private readonly done: (result: boolean) => void,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
get focused(): boolean {
|
|
32
|
+
return this._focused;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
set focused(value: boolean) {
|
|
36
|
+
this._focused = value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
handleInput(input: string): void {
|
|
40
|
+
if (matchesKey(input, Key.escape)) {
|
|
41
|
+
this.finish(false);
|
|
42
|
+
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (matchesKey(input, Key.left) || matchesKey(input, Key.right)) {
|
|
47
|
+
this.selected = this.selected === "yes" ? "no" : "yes";
|
|
48
|
+
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (input.toLowerCase() === "y") {
|
|
53
|
+
this.finish(true);
|
|
54
|
+
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (input.toLowerCase() === "n") {
|
|
59
|
+
this.finish(false);
|
|
60
|
+
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (matchesKey(input, Key.enter) || input === " ") {
|
|
65
|
+
this.finish(this.selected === "yes");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
invalidate(): void {
|
|
70
|
+
// No cached layout or external data to invalidate.
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
render(width: number): string[] {
|
|
74
|
+
const frameWidth = Math.max(2, width);
|
|
75
|
+
const bodyWidth = Math.max(1, frameWidth - 2);
|
|
76
|
+
const messageLines = wrapBody(this.message, bodyWidth - 4);
|
|
77
|
+
const buttons = [
|
|
78
|
+
this.renderButton("yes", "Yes"),
|
|
79
|
+
this.renderButton("no", "No"),
|
|
80
|
+
].join(" ");
|
|
81
|
+
|
|
82
|
+
return renderDialogFrame({
|
|
83
|
+
bodyLines: [
|
|
84
|
+
...messageLines.map((line) => ` ${line}`),
|
|
85
|
+
"",
|
|
86
|
+
centerText(buttons, bodyWidth),
|
|
87
|
+
],
|
|
88
|
+
footer: this.theme.fg("dim", " ←/→ choose · Enter confirm · Esc cancel "),
|
|
89
|
+
title: this.theme.fg("accent", this.theme.bold(this.title)),
|
|
90
|
+
width: frameWidth,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private finish(result: boolean): void {
|
|
95
|
+
if (this.resolved) return;
|
|
96
|
+
this.resolved = true;
|
|
97
|
+
this.done(result);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private renderButton(value: "no" | "yes", label: string): string {
|
|
101
|
+
const text = this.selected === value ? `● ${label}` : `○ ${label}`;
|
|
102
|
+
|
|
103
|
+
return this.selected === value ? this.theme.fg("accent", text) : text;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function openConfirm(
|
|
108
|
+
ctx: Pick<ExtensionCommandContext, "ui">,
|
|
109
|
+
title: string,
|
|
110
|
+
message: string,
|
|
111
|
+
): Promise<boolean> {
|
|
112
|
+
return ctx.ui.custom<boolean>(
|
|
113
|
+
(_tui, theme, _keybindings, done) =>
|
|
114
|
+
new ConfirmComponent(title, message, theme, done),
|
|
115
|
+
{
|
|
116
|
+
overlay: true,
|
|
117
|
+
overlayOptions: {
|
|
118
|
+
anchor: "center",
|
|
119
|
+
margin: 2,
|
|
120
|
+
maxHeight: "50%",
|
|
121
|
+
minWidth: 48,
|
|
122
|
+
width: "50%",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
}
|