taskplane 0.24.15 → 0.24.17
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.
|
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
|
|
|
2
2
|
import { Type } from "@mariozechner/pi-ai";
|
|
3
3
|
|
|
4
4
|
import { execSync, execFileSync } from "child_process";
|
|
5
|
-
import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, createWriteStream } from "fs";
|
|
5
|
+
import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, createWriteStream, renameSync } from "fs";
|
|
6
6
|
import { join, dirname } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import { fork, type ChildProcess } from "child_process";
|
|
@@ -365,6 +365,49 @@ export interface IntegrationExecDeps {
|
|
|
365
365
|
deleteBatchState: () => void;
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
interface BatchHistorySnapshot {
|
|
369
|
+
filePath: string;
|
|
370
|
+
raw: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Preserve `.pi/batch-history.json` across integration merges.
|
|
375
|
+
*
|
|
376
|
+
* Runtime history is sidecar state, not source-controlled content. In environments
|
|
377
|
+
* where `.pi/batch-history.json` was previously tracked, merge/checkouts can
|
|
378
|
+
* replace newer runtime history with stale branch snapshots. This helper snapshots
|
|
379
|
+
* the file before integration and restores it afterward (best effort).
|
|
380
|
+
*/
|
|
381
|
+
export function withPreservedBatchHistory<T>(stateRoot: string, operation: () => T): T {
|
|
382
|
+
const historyPath = join(stateRoot, ".pi", "batch-history.json");
|
|
383
|
+
let snapshot: BatchHistorySnapshot | null = null;
|
|
384
|
+
try {
|
|
385
|
+
if (existsSync(historyPath)) {
|
|
386
|
+
snapshot = {
|
|
387
|
+
filePath: historyPath,
|
|
388
|
+
raw: readFileSync(historyPath, "utf-8"),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
// Best effort only — integration should never fail due to snapshot capture.
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
return operation();
|
|
397
|
+
} finally {
|
|
398
|
+
if (!snapshot) return;
|
|
399
|
+
try {
|
|
400
|
+
const dir = dirname(snapshot.filePath);
|
|
401
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
402
|
+
const tmpPath = snapshot.filePath + ".tmp";
|
|
403
|
+
writeFileSync(tmpPath, snapshot.raw);
|
|
404
|
+
renameSync(tmpPath, snapshot.filePath);
|
|
405
|
+
} catch {
|
|
406
|
+
// Best effort only — never block integration completion.
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
368
411
|
/**
|
|
369
412
|
* Execute the integration operation for the resolved context.
|
|
370
413
|
*
|
|
@@ -1324,10 +1367,13 @@ export function buildIntegrationExecutor(repoRoot: string, opId?: string, stateR
|
|
|
1324
1367
|
},
|
|
1325
1368
|
};
|
|
1326
1369
|
|
|
1327
|
-
const
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1370
|
+
const effectiveStateRoot = stateRoot ?? repoRoot;
|
|
1371
|
+
const result = withPreservedBatchHistory(effectiveStateRoot, () =>
|
|
1372
|
+
executeIntegration(mode as IntegrateMode, {
|
|
1373
|
+
...context,
|
|
1374
|
+
currentBranch: context.baseBranch,
|
|
1375
|
+
}, deps),
|
|
1376
|
+
);
|
|
1331
1377
|
|
|
1332
1378
|
// TP-051: Clean up stale task/* and saved/* branches after successful integration.
|
|
1333
1379
|
// This ensures auto-mode integration (supervisor path) gets the same cleanup
|
|
@@ -3116,44 +3162,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
3116
3162
|
reposToIntegrate.push({ id: "(default)", root: repoRoot });
|
|
3117
3163
|
}
|
|
3118
3164
|
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
const integrationResult = executeIntegration(parsed.mode, resolution as IntegrationContext, {
|
|
3128
|
-
runGit: (gitArgs: string[]) => runGit(gitArgs, repo.root),
|
|
3129
|
-
runCommand: (cmd: string, cmdArgs: string[]) => {
|
|
3130
|
-
try {
|
|
3131
|
-
const stdout = execFileSync(cmd, cmdArgs, {
|
|
3132
|
-
encoding: "utf-8",
|
|
3133
|
-
timeout: 60_000,
|
|
3134
|
-
cwd: repo.root,
|
|
3135
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
3136
|
-
}).trim();
|
|
3137
|
-
return { ok: true, stdout, stderr: "" };
|
|
3138
|
-
} catch (err: unknown) {
|
|
3139
|
-
const e = err as { stdout?: string; stderr?: string; message?: string };
|
|
3140
|
-
return {
|
|
3141
|
-
ok: false,
|
|
3142
|
-
stdout: (e.stdout ?? "").toString().trim(),
|
|
3143
|
-
stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
|
|
3144
|
-
};
|
|
3145
|
-
}
|
|
3146
|
-
},
|
|
3147
|
-
deleteBatchState: () => { /* handled once after all repos */ },
|
|
3148
|
-
});
|
|
3165
|
+
const integrationRun = withPreservedBatchHistory(stateRoot, () => {
|
|
3166
|
+
let totalCommits = 0;
|
|
3167
|
+
const repoMessages: string[] = [];
|
|
3168
|
+
|
|
3169
|
+
for (const repo of reposToIntegrate) {
|
|
3170
|
+
const preCountResult = runGit(["rev-list", "--count", `HEAD..${resolvedOrchBranch}`], repo.root);
|
|
3171
|
+
const repoCommitsBefore = preCountResult.ok ? parseInt(preCountResult.stdout) || 0 : 0;
|
|
3149
3172
|
|
|
3150
|
-
|
|
3151
|
-
|
|
3173
|
+
const integrationResult = executeIntegration(parsed.mode, resolution as IntegrationContext, {
|
|
3174
|
+
runGit: (gitArgs: string[]) => runGit(gitArgs, repo.root),
|
|
3175
|
+
runCommand: (cmd: string, cmdArgs: string[]) => {
|
|
3176
|
+
try {
|
|
3177
|
+
const stdout = execFileSync(cmd, cmdArgs, {
|
|
3178
|
+
encoding: "utf-8",
|
|
3179
|
+
timeout: 60_000,
|
|
3180
|
+
cwd: repo.root,
|
|
3181
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3182
|
+
}).trim();
|
|
3183
|
+
return { ok: true, stdout, stderr: "" };
|
|
3184
|
+
} catch (err: unknown) {
|
|
3185
|
+
const e = err as { stdout?: string; stderr?: string; message?: string };
|
|
3186
|
+
return {
|
|
3187
|
+
ok: false,
|
|
3188
|
+
stdout: (e.stdout ?? "").toString().trim(),
|
|
3189
|
+
stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
},
|
|
3193
|
+
deleteBatchState: () => { /* handled once after all repos */ },
|
|
3194
|
+
});
|
|
3195
|
+
|
|
3196
|
+
if (!integrationResult.success) {
|
|
3197
|
+
return { ok: false as const, error: `❌ Integration failed in ${repo.id}:\n${integrationResult.error}` };
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
totalCommits += repoCommitsBefore;
|
|
3201
|
+
repoMessages.push(` ${repo.id}: ${integrationResult.message}`);
|
|
3152
3202
|
}
|
|
3153
3203
|
|
|
3154
|
-
totalCommits
|
|
3155
|
-
|
|
3204
|
+
return { ok: true as const, totalCommits, repoMessages };
|
|
3205
|
+
});
|
|
3206
|
+
if (!integrationRun.ok) {
|
|
3207
|
+
return { message: integrationRun.error, error: true };
|
|
3156
3208
|
}
|
|
3209
|
+
const { totalCommits, repoMessages } = integrationRun;
|
|
3157
3210
|
|
|
3158
3211
|
// Post-integration cleanup & acceptance
|
|
3159
3212
|
const allRepos: { id: string; root: string }[] = [];
|
|
@@ -1841,18 +1841,21 @@ export function saveBatchHistory(repoRoot: string, summary: BatchHistorySummary)
|
|
|
1841
1841
|
const filePath = batchHistoryPath(repoRoot);
|
|
1842
1842
|
try {
|
|
1843
1843
|
const history = loadBatchHistory(repoRoot);
|
|
1844
|
+
// Upsert by batchId so resumed batches replace their earlier partial entry
|
|
1845
|
+
// instead of creating duplicates.
|
|
1846
|
+
const nextHistory = history.filter(entry => entry.batchId !== summary.batchId);
|
|
1844
1847
|
// Prepend newest first
|
|
1845
|
-
|
|
1848
|
+
nextHistory.unshift(summary);
|
|
1846
1849
|
// Trim to max
|
|
1847
|
-
if (
|
|
1848
|
-
|
|
1850
|
+
if (nextHistory.length > BATCH_HISTORY_MAX_ENTRIES) {
|
|
1851
|
+
nextHistory.length = BATCH_HISTORY_MAX_ENTRIES;
|
|
1849
1852
|
}
|
|
1850
1853
|
const dir = dirname(filePath);
|
|
1851
1854
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1852
1855
|
const tmpPath = filePath + ".tmp";
|
|
1853
|
-
writeFileSync(tmpPath, JSON.stringify(
|
|
1856
|
+
writeFileSync(tmpPath, JSON.stringify(nextHistory, null, 2));
|
|
1854
1857
|
renameSync(tmpPath, filePath);
|
|
1855
|
-
execLog("batch", "history", `saved batch summary (${
|
|
1858
|
+
execLog("batch", "history", `saved batch summary (${nextHistory.length} entries)`);
|
|
1856
1859
|
} catch (err) {
|
|
1857
1860
|
execLog("batch", "history", `failed to save batch history: ${err}`);
|
|
1858
1861
|
}
|
|
@@ -127,7 +127,7 @@ export const SECTIONS: SectionDef[] = [
|
|
|
127
127
|
{
|
|
128
128
|
name: "Merge",
|
|
129
129
|
fields: [
|
|
130
|
-
{ configPath: "orchestrator.merge.model", label: "Merge Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "mergeModel", description: "Merge-agent model (
|
|
130
|
+
{ configPath: "orchestrator.merge.model", label: "Merge Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "mergeModel", description: "Merge-agent model (inherit = use session model)" },
|
|
131
131
|
{ configPath: "orchestrator.merge.tools", label: "Merge Tools", control: "input", layer: "L1", fieldType: "string", description: "Merge-agent tool allowlist" },
|
|
132
132
|
{ configPath: "orchestrator.merge.order", label: "Merge Order", control: "toggle", layer: "L1", fieldType: "enum", values: ["fewest-files-first", "sequential"], description: "Lane merge ordering policy" },
|
|
133
133
|
{ configPath: "orchestrator.merge.timeoutMinutes", label: "Merge Timeout (minutes)", control: "input", layer: "L1", fieldType: "number", description: "Max time for merge agent to complete. Increase for large batches (default: 10)" },
|
|
@@ -152,14 +152,14 @@ export const SECTIONS: SectionDef[] = [
|
|
|
152
152
|
{
|
|
153
153
|
name: "Supervisor",
|
|
154
154
|
fields: [
|
|
155
|
-
{ configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (
|
|
155
|
+
{ configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (inherit = use session model)" },
|
|
156
156
|
{ configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "toggle", layer: "L1", fieldType: "enum", values: ["interactive", "supervised", "autonomous"], description: "Recovery action confirmation behavior" },
|
|
157
157
|
],
|
|
158
158
|
},
|
|
159
159
|
{
|
|
160
160
|
name: "Worker",
|
|
161
161
|
fields: [
|
|
162
|
-
{ configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (
|
|
162
|
+
{ configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (inherit = use session model)" },
|
|
163
163
|
{ configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
|
|
164
164
|
{ configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "input", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
|
|
165
165
|
{ configPath: "taskRunner.worker.spawnMode", label: "Spawn Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["subprocess"], optional: true, description: "How /task spawns workers and reviewers. Runtime V2 supports subprocess only." },
|
|
@@ -168,7 +168,7 @@ export const SECTIONS: SectionDef[] = [
|
|
|
168
168
|
{
|
|
169
169
|
name: "Reviewer",
|
|
170
170
|
fields: [
|
|
171
|
-
{ configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (
|
|
171
|
+
{ configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (inherit = use session model)" },
|
|
172
172
|
{ configPath: "taskRunner.reviewer.tools", label: "Reviewer Tools", control: "input", layer: "L1", fieldType: "string", description: "Reviewer tool allowlist" },
|
|
173
173
|
{ configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "input", layer: "L1", fieldType: "string", description: "Reviewer thinking mode" },
|
|
174
174
|
],
|
|
@@ -929,6 +929,134 @@ function summarizeArray(arr: any[]): string {
|
|
|
929
929
|
* @param configRoot - Workspace/repo root (from execCtx.workspaceRoot)
|
|
930
930
|
* @param pointerConfigRoot - Optional pointer-resolved config root (workspace mode)
|
|
931
931
|
*/
|
|
932
|
+
// ── Model Picker (Sage-style provider → model selection) ────────────
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Interactive two-level model picker: provider first, then model within provider.
|
|
936
|
+
* Returns the selected model string (e.g., "anthropic/claude-sonnet-4-20250514")
|
|
937
|
+
* or "" for inherit, or undefined if cancelled.
|
|
938
|
+
*
|
|
939
|
+
* Adapted from Sage's pickModel implementation.
|
|
940
|
+
*/
|
|
941
|
+
async function pickModel(ctx: ExtensionContext, currentModel: string): Promise<string | undefined> {
|
|
942
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
943
|
+
if (available.length === 0) {
|
|
944
|
+
ctx.ui.notify("No available models found in pi model registry", "warning");
|
|
945
|
+
// Fall back to manual input
|
|
946
|
+
const manual = await ctx.ui.input("Model (provider/model-id, or empty for inherit)", currentModel || "");
|
|
947
|
+
if (manual === null || manual === undefined) return undefined;
|
|
948
|
+
return manual;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const currentLower = (currentModel || "").trim().toLowerCase();
|
|
952
|
+
const providers = [...new Set(available.map((m: any) => m.provider))].sort();
|
|
953
|
+
|
|
954
|
+
while (true) {
|
|
955
|
+
// Level 1: Provider selection (with "inherit" as first option)
|
|
956
|
+
const providerOptions: string[] = [
|
|
957
|
+
"inherit (use current session model)",
|
|
958
|
+
...providers.map((p: string) => {
|
|
959
|
+
const count = available.filter((m: any) => m.provider === p).length;
|
|
960
|
+
return `${p} (${count} models)`;
|
|
961
|
+
}),
|
|
962
|
+
];
|
|
963
|
+
|
|
964
|
+
const providerChoice = await selectScrollable(ctx, "Choose model provider", providerOptions);
|
|
965
|
+
if (!providerChoice) return undefined; // Cancelled
|
|
966
|
+
|
|
967
|
+
if (providerChoice.startsWith("inherit")) {
|
|
968
|
+
return ""; // Empty string = inherit
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Extract provider name (strip " (N models)" suffix)
|
|
972
|
+
const provider = providerChoice.replace(/\s*\(\d+ models?\)$/, "");
|
|
973
|
+
const providerModels = available
|
|
974
|
+
.filter((m: any) => m.provider === provider)
|
|
975
|
+
.sort((a: any, b: any) => {
|
|
976
|
+
// Current model first, then alphabetical
|
|
977
|
+
const aComposite = `${a.provider}/${a.id}`.toLowerCase();
|
|
978
|
+
const bComposite = `${b.provider}/${b.id}`.toLowerCase();
|
|
979
|
+
if (aComposite === currentLower) return -1;
|
|
980
|
+
if (bComposite === currentLower) return 1;
|
|
981
|
+
return a.id.localeCompare(b.id);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// Level 2: Model selection within provider
|
|
985
|
+
const modelOptionMap = new Map<string, string>();
|
|
986
|
+
const modelOptions = ["← Back to providers"];
|
|
987
|
+
|
|
988
|
+
for (const model of providerModels) {
|
|
989
|
+
const composite = `${model.provider}/${model.id}`;
|
|
990
|
+
const isCurrent = composite.toLowerCase() === currentLower;
|
|
991
|
+
const label = `${model.id}${isCurrent ? " ✓ current" : ""}`;
|
|
992
|
+
modelOptions.push(label);
|
|
993
|
+
modelOptionMap.set(label, composite);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const modelChoice = await selectScrollable(ctx, `Choose model (${provider})`, modelOptions);
|
|
997
|
+
if (!modelChoice) continue; // Cancelled → back to providers
|
|
998
|
+
if (modelChoice === "← Back to providers") continue;
|
|
999
|
+
|
|
1000
|
+
const resolved = modelOptionMap.get(modelChoice);
|
|
1001
|
+
if (resolved) return resolved;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Scrollable select list for model/provider picking.
|
|
1007
|
+
* Uses pi's TUI custom widget API.
|
|
1008
|
+
*/
|
|
1009
|
+
async function selectScrollable(
|
|
1010
|
+
ctx: ExtensionContext,
|
|
1011
|
+
title: string,
|
|
1012
|
+
options: string[],
|
|
1013
|
+
maxVisible = 12,
|
|
1014
|
+
): Promise<string | undefined> {
|
|
1015
|
+
if (options.length === 0) return undefined;
|
|
1016
|
+
|
|
1017
|
+
const items: SelectItem[] = options.map((option, index) => ({
|
|
1018
|
+
value: String(index),
|
|
1019
|
+
label: option,
|
|
1020
|
+
}));
|
|
1021
|
+
|
|
1022
|
+
const selectedValue = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
1023
|
+
const container = new Container();
|
|
1024
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1025
|
+
container.addChild(new Text(theme.fg("accent", title), 1, 0));
|
|
1026
|
+
container.addChild(new Text("", 0, 0));
|
|
1027
|
+
|
|
1028
|
+
const selectList = new SelectList(items, Math.max(3, Math.min(maxVisible, items.length)), {
|
|
1029
|
+
selectedPrefix: (text: string) => theme.fg("accent", text),
|
|
1030
|
+
selectedText: (text: string) => theme.fg("accent", text),
|
|
1031
|
+
description: (text: string) => theme.fg("muted", text),
|
|
1032
|
+
scrollInfo: (text: string) => theme.fg("dim", text),
|
|
1033
|
+
noMatch: (text: string) => theme.fg("warning", text),
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
selectList.onSelect = (item) => done(item.value);
|
|
1037
|
+
selectList.onCancel = () => done(undefined);
|
|
1038
|
+
container.addChild(selectList);
|
|
1039
|
+
|
|
1040
|
+
container.addChild(new Text("", 0, 0));
|
|
1041
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • type to filter • enter select • esc back"), 1, 0));
|
|
1042
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1043
|
+
|
|
1044
|
+
return {
|
|
1045
|
+
render: (width: number) => container.render(width),
|
|
1046
|
+
invalidate: () => container.invalidate(),
|
|
1047
|
+
handleInput: (data: string) => {
|
|
1048
|
+
selectList.handleInput(data);
|
|
1049
|
+
tui.requestRender();
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
if (selectedValue === undefined) return undefined;
|
|
1055
|
+
const selectedIndex = Number(selectedValue);
|
|
1056
|
+
if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= options.length) return undefined;
|
|
1057
|
+
return options[selectedIndex];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
932
1060
|
export async function openSettingsTui(
|
|
933
1061
|
ctx: ExtensionContext,
|
|
934
1062
|
configRoot: string,
|
|
@@ -1120,26 +1248,36 @@ async function showSectionSettingsLoop(
|
|
|
1120
1248
|
|
|
1121
1249
|
// Input fields: the submenu returned a sentinel — use ctx.ui.input() for actual editing
|
|
1122
1250
|
if (result.rawValue === "__EDIT_REQUESTED__" && field.control === "input") {
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1251
|
+
// Model fields: use interactive provider → model picker instead of free-text
|
|
1252
|
+
if (field.configPath.endsWith(".model")) {
|
|
1253
|
+
const state = loadConfigState(configRoot, pointerConfigRoot);
|
|
1254
|
+
const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
|
|
1255
|
+
const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
|
|
1256
|
+
const selected = await pickModel(ctx, currentClean === "(inherit)" ? "" : currentClean);
|
|
1257
|
+
if (selected === undefined) continue; // Cancelled
|
|
1258
|
+
result.rawValue = selected;
|
|
1259
|
+
} else {
|
|
1260
|
+
const state = loadConfigState(configRoot, pointerConfigRoot);
|
|
1261
|
+
const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
|
|
1262
|
+
const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
|
|
1263
|
+
const placeholder = currentClean === "(not set)" || currentClean === "(inherit)" ? "" : currentClean;
|
|
1264
|
+
|
|
1265
|
+
const newValue = await ctx.ui.input(
|
|
1266
|
+
`${field.label}${field.description ? ` — ${field.description}` : ""}`,
|
|
1267
|
+
placeholder,
|
|
1268
|
+
);
|
|
1269
|
+
|
|
1270
|
+
if (newValue === null || newValue === undefined) continue; // Cancelled
|
|
1271
|
+
|
|
1272
|
+
// Validate
|
|
1273
|
+
const validation = validateFieldInput(field, newValue);
|
|
1274
|
+
if (!validation.valid) {
|
|
1275
|
+
ctx.ui.notify(`❌ Invalid value: ${validation.error}`, "error");
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1134
1278
|
|
|
1135
|
-
|
|
1136
|
-
const validation = validateFieldInput(field, newValue);
|
|
1137
|
-
if (!validation.valid) {
|
|
1138
|
-
ctx.ui.notify(`❌ Invalid value: ${validation.error}`, "error");
|
|
1139
|
-
continue;
|
|
1279
|
+
result.rawValue = newValue;
|
|
1140
1280
|
}
|
|
1141
|
-
|
|
1142
|
-
result.rawValue = newValue;
|
|
1143
1281
|
}
|
|
1144
1282
|
|
|
1145
1283
|
const typedValue = coerceValueForWrite(field, result.rawValue);
|