@gtrabanco/pi-agentic-workflow 0.7.2 → 0.8.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/README.es.md +22 -3
- package/README.md +21 -3
- package/dist/config/schema.js +32 -1
- package/dist/config/types.d.ts +10 -1
- package/dist/config/types.js +2 -0
- package/dist/extension/factory.js +26 -15
- package/dist/extension/index.js +44 -1
- package/dist/routing/dispatch.js +39 -3
- package/dist/routing/types.d.ts +15 -0
- package/dist/routing/types.js +2 -0
- package/dist/settings/console.d.ts +11 -0
- package/dist/settings/console.js +203 -19
- package/dist/settings/picker.d.ts +31 -0
- package/dist/settings/picker.js +93 -0
- package/dist/settings/view.js +4 -1
- package/package.json +4 -1
package/README.es.md
CHANGED
|
@@ -74,7 +74,7 @@ Dos archivos JSON, ambos opcionales:
|
|
|
74
74
|
{
|
|
75
75
|
"default": { "model": "anthropic/claude-opus-4-5", "thinking": "high" },
|
|
76
76
|
"commands": {
|
|
77
|
-
"plan-feature": { "model": "anthropic/claude-sonnet-4-5", "thinking": "medium" },
|
|
77
|
+
"plan-feature": { "model": ["anthropic/claude-sonnet-4-5", "openai/gpt-5.2"], "thinking": "medium" },
|
|
78
78
|
"review-change": { "thinking": "max" }
|
|
79
79
|
},
|
|
80
80
|
"onUnavailableRoute": "stop"
|
|
@@ -87,8 +87,14 @@ de arriba usa el modelo del default con thinking `max`; todo lo demás corre con
|
|
|
87
87
|
que ya tuviera la sesión, porque la ruta por defecto del paquete es
|
|
88
88
|
`{"model": "inherit", "thinking": "inherit"}`.
|
|
89
89
|
|
|
90
|
-
- `model`
|
|
91
|
-
`/model` —
|
|
90
|
+
- `model` puede ser `provider/modelId` — la referencia exacta que muestra
|
|
91
|
+
`/model` — `"inherit"`, o un **array** ordenado de 1–4 referencias (una cadena
|
|
92
|
+
de respaldo). Para una cadena, el dispatch prueba cada referencia en orden y
|
|
93
|
+
aplica la primera que se resuelve y tiene credenciales, sin tocar la sesión
|
|
94
|
+
mientras prueba; cuando todas las entradas son inservibles el comando se
|
|
95
|
+
detiene (o, con `onUnavailableRoute` en `inherit`, avisa y corre con el modelo
|
|
96
|
+
actual), nombrando cada candidato y por qué se saltó. `plan-feature` de arriba
|
|
97
|
+
prueba `anthropic/claude-sonnet-4-5` primero y luego `openai/gpt-5.2`.
|
|
92
98
|
- `thinking` es uno de `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`,
|
|
93
99
|
o `"inherit"`.
|
|
94
100
|
- Claves desconocidas, `null` y referencias mal formadas se **rechazan**, no se
|
|
@@ -125,11 +131,24 @@ vuelve.
|
|
|
125
131
|
/agentic-workflow-settings
|
|
126
132
|
```
|
|
127
133
|
|
|
134
|
+
`/aw-settings` es un atajo para la misma consola.
|
|
135
|
+
|
|
128
136
|
Muestra en qué corre cada comando ahora mismo, y qué archivo se niega a parsearse,
|
|
129
137
|
y luego deja editar **un archivo a la vez** y guardar en alcance global o de
|
|
130
138
|
proyecto. No guarda encima de un archivo que no sabe leer, y no toca el
|
|
131
139
|
archivo de proyecto mientras el proyecto no sea de confianza.
|
|
132
140
|
|
|
141
|
+
Los selectores de modelo y thinking de la consola son buscables y con ventana:
|
|
142
|
+
escribir estrecha la lista, el cursor se queda en pantalla, un indicador de
|
|
143
|
+
posición muestra dónde estás y el valor en vigor viene preseleccionado y
|
|
144
|
+
etiquetado `(current)` / `(default route)`. Una edición de ruta pregunta qué
|
|
145
|
+
campo cambiar (model, thinking), así que guardar sin cambios deja el archivo
|
|
146
|
+
byte a byte idéntico; el campo de modelo puede construir una cadena de respaldo
|
|
147
|
+
ordenada (`a/m1 → b/m2`); y una sola pasada puede aplicar o limpiar una ruta en
|
|
148
|
+
varios comandos, avisando por comando cuando una referencia falta del registro
|
|
149
|
+
vivo. Fuera de una sesión TUI los selectores caen a un prompt simple, así que la
|
|
150
|
+
consola nunca se queda sin salida.
|
|
151
|
+
|
|
133
152
|
## Diagnóstico
|
|
134
153
|
|
|
135
154
|
| Ves | Significa |
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ Two JSON files, both optional:
|
|
|
72
72
|
{
|
|
73
73
|
"default": { "model": "anthropic/claude-opus-4-5", "thinking": "high" },
|
|
74
74
|
"commands": {
|
|
75
|
-
"plan-feature": { "model": "anthropic/claude-sonnet-4-5", "thinking": "medium" },
|
|
75
|
+
"plan-feature": { "model": ["anthropic/claude-sonnet-4-5", "openai/gpt-5.2"], "thinking": "medium" },
|
|
76
76
|
"review-change": { "thinking": "max" }
|
|
77
77
|
},
|
|
78
78
|
"onUnavailableRoute": "stop"
|
|
@@ -85,8 +85,14 @@ above runs on the default model with `max` thinking; anything else runs on
|
|
|
85
85
|
whatever the session already had, because the shipped default route is
|
|
86
86
|
`{"model": "inherit", "thinking": "inherit"}`.
|
|
87
87
|
|
|
88
|
-
- `model`
|
|
89
|
-
`"inherit"
|
|
88
|
+
- `model` can be `provider/modelId` — the exact reference `/model` shows —
|
|
89
|
+
`"inherit"`, or an ordered **array** of 1–4 references (a fallback chain). For
|
|
90
|
+
a chain, dispatch probes each reference in order and applies the first one that
|
|
91
|
+
resolves and has credentials, without touching the session while probing; when
|
|
92
|
+
every entry is unusable the command stops (or, with `onUnavailableRoute`
|
|
93
|
+
`inherit`, warns and runs on the current model), naming each candidate and why
|
|
94
|
+
it was skipped. `plan-feature` above tries `anthropic/claude-sonnet-4-5` first,
|
|
95
|
+
then `openai/gpt-5.2`.
|
|
90
96
|
- `thinking` is one of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`,
|
|
91
97
|
or `"inherit"`.
|
|
92
98
|
- Unknown keys, `null`s and malformed references are **rejected**, not ignored: a
|
|
@@ -120,10 +126,22 @@ thinking level and you keep it while the model still comes back.
|
|
|
120
126
|
/agentic-workflow-settings
|
|
121
127
|
```
|
|
122
128
|
|
|
129
|
+
`/aw-settings` is a shorthand for the same console.
|
|
130
|
+
|
|
123
131
|
Shows what each command runs on right now, and which file is refusing to parse,
|
|
124
132
|
then lets you edit **one file at a time** and save to global or project scope. It will not save over a file it cannot parse, and it will not
|
|
125
133
|
touch the project file while the project is untrusted.
|
|
126
134
|
|
|
135
|
+
The console's model and thinking pickers are searchable and windowed: typing
|
|
136
|
+
narrows the list, the cursor stays on screen, a position indicator shows where
|
|
137
|
+
you are, and the value currently in force is pre-selected and labelled
|
|
138
|
+
`(current)` / `(default route)`. A route edit asks which field to change
|
|
139
|
+
(model, thinking), so a no-change save leaves the file byte-identical; the
|
|
140
|
+
model field can build an ordered fallback chain (`a/m1 → b/m2`); and one pass
|
|
141
|
+
can apply or clear a route across several commands, warning per command when a
|
|
142
|
+
reference is missing from the live registry. Outside a TUI session the pickers
|
|
143
|
+
fall back to a plain prompt, so the console never dead-ends.
|
|
144
|
+
|
|
127
145
|
## Troubleshooting
|
|
128
146
|
|
|
129
147
|
| You see | Means |
|
package/dist/config/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "./types.js";
|
|
1
|
+
import { MAX_MODEL_CHAIN, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Strict validator for one config file (SPEC S5-S8, D-E5).
|
|
4
4
|
*
|
|
@@ -52,6 +52,37 @@ function checkRoute(value, path, issues) {
|
|
|
52
52
|
continue;
|
|
53
53
|
}
|
|
54
54
|
if (key === "model") {
|
|
55
|
+
if (Array.isArray(entry)) {
|
|
56
|
+
// A chain must be non-empty, hold only references, and stay within the cap.
|
|
57
|
+
if (entry.length === 0) {
|
|
58
|
+
issues.push({
|
|
59
|
+
path: `${path}.model`,
|
|
60
|
+
message: `must be "inherit", "provider/modelId", or a non-empty chain of references, got an empty array`,
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (entry.length > MAX_MODEL_CHAIN) {
|
|
65
|
+
issues.push({
|
|
66
|
+
path: `${path}.model`,
|
|
67
|
+
message: `chain holds ${entry.length} entries; ${MAX_MODEL_CHAIN} is the maximum`,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
let chainValid = true;
|
|
72
|
+
for (let index = 0; index < entry.length; index += 1) {
|
|
73
|
+
if (!isModelReference(entry[index])) {
|
|
74
|
+
issues.push({
|
|
75
|
+
path: `${path}.model`,
|
|
76
|
+
message: `chain entry ${index + 1} (${describe(entry[index])}) must be "provider/modelId"`,
|
|
77
|
+
});
|
|
78
|
+
chainValid = false;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (chainValid)
|
|
83
|
+
route.model = entry;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
55
86
|
if (entry !== "inherit" && !isModelReference(entry)) {
|
|
56
87
|
issues.push({
|
|
57
88
|
path: `${path}.model`,
|
package/dist/config/types.d.ts
CHANGED
|
@@ -14,7 +14,16 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
|
14
14
|
export type ThinkingSetting = ThinkingLevel | "inherit";
|
|
15
15
|
/** `"inherit"` or an exact `provider/modelId` reference (split at the first slash). */
|
|
16
16
|
export type ModelRef = `${string}/${string}`;
|
|
17
|
-
|
|
17
|
+
/** An ordered fallback chain of references; the first resolvable + configured entry wins (issue #154, root cause E). */
|
|
18
|
+
export type ModelChain = readonly ModelRef[];
|
|
19
|
+
/**
|
|
20
|
+
* `"inherit"`, one reference, or an ordered fallback chain of 1–4 references.
|
|
21
|
+
* A chain is probed by order, and the first entry that resolves AND has
|
|
22
|
+
* configured auth is applied (OB-6, OB-8, OB-9).
|
|
23
|
+
*/
|
|
24
|
+
export type ModelSetting = "inherit" | ModelRef | ModelChain;
|
|
25
|
+
/** Cap on chain length (issue deliberation: 8 was "almost certainly a mistake"; 4 keeps the view readable). */
|
|
26
|
+
export declare const MAX_MODEL_CHAIN = 4;
|
|
18
27
|
export interface Route {
|
|
19
28
|
model: ModelSetting;
|
|
20
29
|
thinking: ThinkingSetting;
|
package/dist/config/types.js
CHANGED
|
@@ -9,4 +9,6 @@
|
|
|
9
9
|
*/
|
|
10
10
|
/** Pi's thinking levels, mirrored here so the domain layer stays Pi-free. */
|
|
11
11
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
12
|
+
/** Cap on chain length (issue deliberation: 8 was "almost certainly a mistake"; 4 keeps the view readable). */
|
|
13
|
+
export const MAX_MODEL_CHAIN = 4;
|
|
12
14
|
export const UNAVAILABLE_ROUTE_POLICIES = ["stop", "inherit"];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadConfig } from "../config/load.js";
|
|
2
2
|
import { readCatalogue } from "../routing/catalogue.js";
|
|
3
3
|
import { createRouter } from "../routing/dispatch.js";
|
|
4
|
-
import { SETTINGS_COMMAND } from "../routing/types.js";
|
|
4
|
+
import { SETTINGS_COMMAND, SETTINGS_COMMAND_ALIAS } from "../routing/types.js";
|
|
5
5
|
export function createExtension(deps) {
|
|
6
6
|
const { registrar, surface, skillsDir, agentDir, hint, settings } = deps;
|
|
7
7
|
const catalogue = readCatalogue(skillsDir);
|
|
@@ -18,7 +18,11 @@ export function createExtension(deps) {
|
|
|
18
18
|
ctx.notify(`pi-agentic-workflow: skills/${issue.dir}: ${issue.message}`, "warning");
|
|
19
19
|
}
|
|
20
20
|
};
|
|
21
|
-
const knownCommands = new Set([
|
|
21
|
+
const knownCommands = new Set([
|
|
22
|
+
...catalogue.commands.map((entry) => entry.name),
|
|
23
|
+
SETTINGS_COMMAND,
|
|
24
|
+
SETTINGS_COMMAND_ALIAS,
|
|
25
|
+
]);
|
|
22
26
|
const router = createRouter({ surface, loadConfig: read, hint, settingsCommand: SETTINGS_COMMAND, knownCommands });
|
|
23
27
|
for (const command of catalogue.commands) {
|
|
24
28
|
registrar.registerCommand(command.name, {
|
|
@@ -29,21 +33,28 @@ export function createExtension(deps) {
|
|
|
29
33
|
},
|
|
30
34
|
});
|
|
31
35
|
}
|
|
36
|
+
// One handler for both names — `/aw-settings` is a pointer to the same console
|
|
37
|
+
// and the same two config files, never a separate route surface (OB-5, AC6).
|
|
38
|
+
const settingsHandler = async (_args, ctx) => {
|
|
39
|
+
reportCatalogueIssues(ctx);
|
|
40
|
+
// Bound for the console: two verbs and the session they act on, so it cannot
|
|
41
|
+
// reach for `settle` or `dispatch` by accident.
|
|
42
|
+
const routing = { inFlight: () => router.inFlight(), undoInFlight: () => router.undoInFlight(ctx) };
|
|
43
|
+
try {
|
|
44
|
+
await settings({ catalogue, ctx, routing });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// A console that dies mid-question must say so, not take the session down.
|
|
48
|
+
ctx.notify(`Settings could not be opened: ${error.message}`, "error");
|
|
49
|
+
}
|
|
50
|
+
};
|
|
32
51
|
registrar.registerCommand(SETTINGS_COMMAND, {
|
|
33
52
|
description: "Show and configure per-command model routing",
|
|
34
|
-
handler:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
await settings({ catalogue, ctx, routing });
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
// A console that dies mid-question must say so, not take the session down.
|
|
44
|
-
ctx.notify(`Settings could not be opened: ${error.message}`, "error");
|
|
45
|
-
}
|
|
46
|
-
},
|
|
53
|
+
handler: settingsHandler,
|
|
54
|
+
});
|
|
55
|
+
registrar.registerCommand(SETTINGS_COMMAND_ALIAS, {
|
|
56
|
+
description: "Show and configure per-command model routing",
|
|
57
|
+
handler: settingsHandler,
|
|
47
58
|
});
|
|
48
59
|
return { router, catalogue };
|
|
49
60
|
}
|
package/dist/extension/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { loadConfig } from "../config/load.js";
|
|
5
5
|
import { createExtension } from "./factory.js";
|
|
6
|
+
import { createPickerComponent, PICKER_MAX_VISIBLE } from "../settings/picker.js";
|
|
6
7
|
import { createHintStore, stateFilePath } from "../routing/state.js";
|
|
7
8
|
import { runSettingsConsole } from "../settings/console.js";
|
|
8
9
|
import { readConfigFile, writeConfigFile } from "../settings/store.js";
|
|
@@ -20,6 +21,48 @@ import { SETTINGS_COMMAND } from "../routing/types.js";
|
|
|
20
21
|
*/
|
|
21
22
|
const skillsDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
|
|
22
23
|
const thinkingLevelsInSyncWithPi = true;
|
|
24
|
+
/**
|
|
25
|
+
* The interactive slice the settings console sees: Pi's own `ui` plus the rich
|
|
26
|
+
* picker (P4). The picker is terminal-only, so a non-TUI mode (headless/RPC)
|
|
27
|
+
* gets the plain `select` path — the console never dead-ends (OB-12, PE-004).
|
|
28
|
+
*/
|
|
29
|
+
function richUi(ctx) {
|
|
30
|
+
const base = ctx.ui;
|
|
31
|
+
return {
|
|
32
|
+
select: (title, options) => base.select(title, [...options]),
|
|
33
|
+
input: (title, placeholder) => base.input(title, placeholder),
|
|
34
|
+
confirm: (title, message) => base.confirm(title, message),
|
|
35
|
+
notify: (message, kind) => base.notify(message, kind),
|
|
36
|
+
pick: async (title, options, opts = {}) => {
|
|
37
|
+
if (ctx.mode !== "tui") {
|
|
38
|
+
const picked = await base.select(title, [...options]);
|
|
39
|
+
if (opts.multiple)
|
|
40
|
+
return picked ? [picked] : undefined;
|
|
41
|
+
return picked;
|
|
42
|
+
}
|
|
43
|
+
const items = options.map((value) => ({ value, label: value }));
|
|
44
|
+
const result = await base.custom((_tui, theme, _keybindings, done) => {
|
|
45
|
+
const selectListTheme = {
|
|
46
|
+
selectedPrefix: (text) => theme.bold(`› ${text}`),
|
|
47
|
+
selectedText: (text) => theme.inverse(text),
|
|
48
|
+
description: (text) => ` ${text}`,
|
|
49
|
+
scrollInfo: (text) => theme.bold(text),
|
|
50
|
+
noMatch: (text) => theme.italic(text),
|
|
51
|
+
};
|
|
52
|
+
return createPickerComponent({
|
|
53
|
+
items,
|
|
54
|
+
maxVisible: PICKER_MAX_VISIBLE,
|
|
55
|
+
theme: selectListTheme,
|
|
56
|
+
onSelect: (value) => done(value),
|
|
57
|
+
onCancel: () => done(undefined),
|
|
58
|
+
});
|
|
59
|
+
}, { overlay: true });
|
|
60
|
+
if (opts.multiple)
|
|
61
|
+
return result === undefined ? undefined : [result];
|
|
62
|
+
return result;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
23
66
|
function toInvocationContext(ctx) {
|
|
24
67
|
return {
|
|
25
68
|
cwd: ctx.cwd,
|
|
@@ -29,7 +72,7 @@ function toInvocationContext(ctx) {
|
|
|
29
72
|
isIdle: () => ctx.isIdle(),
|
|
30
73
|
isProjectTrusted: () => ctx.isProjectTrusted(),
|
|
31
74
|
notify: (message, kind) => ctx.ui.notify(message, kind),
|
|
32
|
-
ui: ctx
|
|
75
|
+
ui: richUi(ctx),
|
|
33
76
|
availableModels: () => ctx.modelRegistry.getAll(),
|
|
34
77
|
find: (provider, modelId) => ctx.modelRegistry.find(provider, modelId),
|
|
35
78
|
hasConfiguredAuth: (model) => ctx.modelRegistry.hasConfiguredAuth(model),
|
package/dist/routing/dispatch.js
CHANGED
|
@@ -111,7 +111,13 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
|
|
|
111
111
|
const session = surface(ctx);
|
|
112
112
|
const route = effectiveRoute(loaded.config, command.name);
|
|
113
113
|
let target;
|
|
114
|
-
|
|
114
|
+
/** The reference string behind `target`, for the select-failure message (a chain names the chosen entry). */
|
|
115
|
+
let chosenRef;
|
|
116
|
+
if (route.model === "inherit") {
|
|
117
|
+
// inherit: no probe — run on the session model (OB-9).
|
|
118
|
+
}
|
|
119
|
+
else if (typeof route.model === "string") {
|
|
120
|
+
// Single reference: behaviour and message shape unchanged (OB-9).
|
|
115
121
|
const reference = parseModelReference(route.model);
|
|
116
122
|
const found = reference ? ctx.find(reference.provider, reference.id) : undefined;
|
|
117
123
|
const blocker = !found
|
|
@@ -127,6 +133,36 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
|
|
|
127
133
|
}
|
|
128
134
|
else {
|
|
129
135
|
target = found;
|
|
136
|
+
chosenRef = route.model;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// Chain: probe entries in order with only ctx.find + hasConfiguredAuth
|
|
141
|
+
// (no session mutation), collecting one skip reason per entry, and apply
|
|
142
|
+
// the first usable one (OB-8, OB-9, OB-10).
|
|
143
|
+
const reasons = [];
|
|
144
|
+
for (const reference of route.model) {
|
|
145
|
+
const parsed = parseModelReference(reference);
|
|
146
|
+
const found = parsed ? ctx.find(parsed.provider, parsed.id) : undefined;
|
|
147
|
+
if (!found) {
|
|
148
|
+
reasons.push(`${reference} is not in the model registry`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!ctx.hasConfiguredAuth(found)) {
|
|
152
|
+
reasons.push(`${reference} has no configured credentials`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
target = found;
|
|
156
|
+
chosenRef = reference;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
if (!target) {
|
|
160
|
+
const tried = route.model.join(", ");
|
|
161
|
+
const detail = reasons.join("; ");
|
|
162
|
+
if (loaded.config.onUnavailableRoute !== "inherit") {
|
|
163
|
+
return refuse(ctx, "unavailable-route", `/${command.name} stopped: the configured model chain ${tried} is unavailable (${detail}). ${configureHint}`);
|
|
164
|
+
}
|
|
165
|
+
ctx.notify(`/${command.name}: the configured model chain ${tried} is unavailable (${detail}), so it runs on the current session model. ${configureHint}`, "warning");
|
|
130
166
|
}
|
|
131
167
|
}
|
|
132
168
|
const snapshot = { model: ctx.model, thinking: session.getThinkingLevel() };
|
|
@@ -135,9 +171,9 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
|
|
|
135
171
|
const selected = await session.setModel(target);
|
|
136
172
|
if (!selected) {
|
|
137
173
|
if (loaded.config.onUnavailableRoute !== "inherit") {
|
|
138
|
-
return refuse(ctx, "unavailable-route", `/${command.name} stopped: ${
|
|
174
|
+
return refuse(ctx, "unavailable-route", `/${command.name} stopped: ${chosenRef} could not be selected. ${configureHint}`);
|
|
139
175
|
}
|
|
140
|
-
ctx.notify(`/${command.name}: ${
|
|
176
|
+
ctx.notify(`/${command.name}: ${chosenRef} could not be selected, so it runs on the current session model. ${configureHint}`, "warning");
|
|
141
177
|
}
|
|
142
178
|
else {
|
|
143
179
|
applied.model = target;
|
package/dist/routing/types.d.ts
CHANGED
|
@@ -39,12 +39,25 @@ export interface ExtensionSurface<M extends ModelRef = ModelRef> {
|
|
|
39
39
|
getThinkingLevel(): ThinkingLevel;
|
|
40
40
|
setThinkingLevel(level: ThinkingLevel): void;
|
|
41
41
|
}
|
|
42
|
+
/** Options for the optional rich picker (OB-1, OB-3, P4/P6). */
|
|
43
|
+
export interface PickerOptions {
|
|
44
|
+
/** The value already in force, preselected when the picker opens (OB-3). */
|
|
45
|
+
initial?: string;
|
|
46
|
+
/** Allow selecting several entries (bulk apply/clear, P6). */
|
|
47
|
+
multiple?: boolean;
|
|
48
|
+
}
|
|
42
49
|
/** The interactive slice of `ctx.ui` the settings console uses (AC10). */
|
|
43
50
|
export interface SettingsUi {
|
|
44
51
|
select(title: string, options: readonly string[]): Promise<string | undefined> | string | undefined;
|
|
45
52
|
input(title: string, placeholder?: string): Promise<string | undefined> | string | undefined;
|
|
46
53
|
confirm(title: string, message: string): Promise<boolean> | boolean;
|
|
47
54
|
notify(message: string, kind?: "info" | "warning" | "error"): void;
|
|
55
|
+
/**
|
|
56
|
+
* Optional rich picker: filterable, windowed, with a position indicator and a
|
|
57
|
+
* preselected current value. A structural superset — a caller without it
|
|
58
|
+
* falls back to `select`/`input`. Returns `readonly string[]` when `multiple`.
|
|
59
|
+
*/
|
|
60
|
+
pick?(title: string, options: readonly string[], opts?: PickerOptions): Promise<string | readonly string[] | undefined> | string | readonly string[] | undefined;
|
|
48
61
|
}
|
|
49
62
|
/**
|
|
50
63
|
* Everything a command handler needs from the invocation, reduced to what we
|
|
@@ -78,6 +91,8 @@ export type DispatchOutcome = {
|
|
|
78
91
|
};
|
|
79
92
|
/** Slash name of the settings console (SPEC S4, AC3, AC10). */
|
|
80
93
|
export declare const SETTINGS_COMMAND = "agentic-workflow-settings";
|
|
94
|
+
/** Shorthand alias for the same console — a pointer to the same handler, never a separate route (OB-5, AC6). */
|
|
95
|
+
export declare const SETTINGS_COMMAND_ALIAS = "aw-settings";
|
|
81
96
|
/**
|
|
82
97
|
* The router, as the settings console needs it: two verbs, already bound to the
|
|
83
98
|
* session they act on. Declared once because both the console's deps and the
|
package/dist/routing/types.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export const modelRefKey = (model) => `${model.provider}/${model.id}`;
|
|
2
2
|
/** Slash name of the settings console (SPEC S4, AC3, AC10). */
|
|
3
3
|
export const SETTINGS_COMMAND = "agentic-workflow-settings";
|
|
4
|
+
/** Shorthand alias for the same console — a pointer to the same handler, never a separate route (OB-5, AC6). */
|
|
5
|
+
export const SETTINGS_COMMAND_ALIAS = "aw-settings";
|
|
@@ -45,11 +45,22 @@ export declare const prompts: {
|
|
|
45
45
|
readonly setDefaultRoute: "Set the default route";
|
|
46
46
|
readonly setOverride: "Set a command override";
|
|
47
47
|
readonly clearOverride: "Clear a command override";
|
|
48
|
+
readonly bulkApply: "Apply one route to several commands";
|
|
49
|
+
readonly bulkClear: "Clear several overrides";
|
|
50
|
+
readonly addAnother: "Add another?";
|
|
48
51
|
readonly policy: "Set the unavailable-route policy";
|
|
49
52
|
readonly save: "Save";
|
|
50
53
|
readonly cancel: "Cancel";
|
|
51
54
|
readonly model: (target: string) => string;
|
|
52
55
|
readonly modelPicked: (target: string) => string;
|
|
53
56
|
readonly thinking: (target: string) => string;
|
|
57
|
+
readonly fields: "Which fields should change?";
|
|
58
|
+
readonly fieldsBoth: "model and thinking";
|
|
59
|
+
readonly fieldsModel: "model only";
|
|
60
|
+
readonly fieldsThinking: "thinking only";
|
|
61
|
+
readonly chainAction: (target: string) => string;
|
|
62
|
+
readonly chainAppend: "Add a fallback";
|
|
63
|
+
readonly chainRemoveLast: "Remove the last";
|
|
64
|
+
readonly chainDone: "Done";
|
|
54
65
|
};
|
|
55
66
|
export declare function runSettingsConsole(deps: SettingsDeps): Promise<ConsoleOutcome>;
|
package/dist/settings/console.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// the operator must fix, not noise to clobber), and the project scope is refused
|
|
9
9
|
// while the project is untrusted — the same gate the loader applies (AC13).
|
|
10
10
|
import { loadConfig, configFilePaths } from "../config/load.js";
|
|
11
|
+
import { effectiveRoute } from "../config/merge.js";
|
|
11
12
|
import { parseConfigFile, parseModelReference } from "../config/schema.js";
|
|
12
|
-
import { THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "../config/types.js";
|
|
13
|
+
import { MAX_MODEL_CHAIN, THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "../config/types.js";
|
|
13
14
|
import { renderMergedConfig, routePath, DEFAULT_ROUTE } from "./view.js";
|
|
14
15
|
/** The console's questions. `test/settings-console.test.mjs` drives the flow
|
|
15
16
|
* through these strings, so renaming one fails the tests that use it rather than
|
|
@@ -27,12 +28,23 @@ export const prompts = {
|
|
|
27
28
|
setDefaultRoute: "Set the default route",
|
|
28
29
|
setOverride: "Set a command override",
|
|
29
30
|
clearOverride: "Clear a command override",
|
|
31
|
+
bulkApply: "Apply one route to several commands",
|
|
32
|
+
bulkClear: "Clear several overrides",
|
|
33
|
+
addAnother: "Add another?",
|
|
30
34
|
policy: "Set the unavailable-route policy",
|
|
31
35
|
save: "Save",
|
|
32
36
|
cancel: "Cancel",
|
|
33
37
|
model: (target) => `Model for ${target}?`,
|
|
34
38
|
modelPicked: (target) => `Which model for ${target}?`,
|
|
35
39
|
thinking: (target) => `Thinking level for ${target}?`,
|
|
40
|
+
fields: "Which fields should change?",
|
|
41
|
+
fieldsBoth: "model and thinking",
|
|
42
|
+
fieldsModel: "model only",
|
|
43
|
+
fieldsThinking: "thinking only",
|
|
44
|
+
chainAction: (target) => `Model chain for ${target}?`,
|
|
45
|
+
chainAppend: "Add a fallback",
|
|
46
|
+
chainRemoveLast: "Remove the last",
|
|
47
|
+
chainDone: "Done",
|
|
36
48
|
};
|
|
37
49
|
const GLOBAL_LABEL = "Global";
|
|
38
50
|
const PROJECT_LABEL = "Project";
|
|
@@ -40,7 +52,10 @@ const TYPED = "Type another reference…";
|
|
|
40
52
|
const INHERIT = "inherit";
|
|
41
53
|
export async function runSettingsConsole(deps) {
|
|
42
54
|
const paths = configFilePaths(deps.agentDir, deps.cwd);
|
|
43
|
-
|
|
55
|
+
const merged = loadConfig({ agentDir: deps.agentDir, cwd: deps.cwd, projectTrusted: deps.projectTrusted, readFile: deps.readFile });
|
|
56
|
+
deps.ui.notify(renderMergedConfig(merged, deps.commands).join("\n"), "info");
|
|
57
|
+
/** The value in force for a target (the merged route, or the default when the target is "the default route"). */
|
|
58
|
+
const currentFor = (target) => effectiveRoute(merged.config, target);
|
|
44
59
|
const opened = await openAScope(deps, paths.global, paths.project);
|
|
45
60
|
if (!opened)
|
|
46
61
|
return { status: "cancelled", edited: false };
|
|
@@ -53,6 +68,8 @@ export async function runSettingsConsole(deps) {
|
|
|
53
68
|
prompts.setDefaultRoute,
|
|
54
69
|
prompts.setOverride,
|
|
55
70
|
prompts.clearOverride,
|
|
71
|
+
prompts.bulkApply,
|
|
72
|
+
prompts.bulkClear,
|
|
56
73
|
prompts.policy,
|
|
57
74
|
...(deps.routing?.inFlight() ? [prompts.undoInFlight] : []),
|
|
58
75
|
prompts.save,
|
|
@@ -67,7 +84,7 @@ export async function runSettingsConsole(deps) {
|
|
|
67
84
|
return { status: "cancelled", edited: dirty(draft, original) ? await discard(deps.ui) : false };
|
|
68
85
|
}
|
|
69
86
|
if (choice === prompts.setDefaultRoute) {
|
|
70
|
-
const edited = await editRoute(deps, DEFAULT_ROUTE);
|
|
87
|
+
const edited = await editRoute(deps, DEFAULT_ROUTE, currentFor(DEFAULT_ROUTE));
|
|
71
88
|
if (edited)
|
|
72
89
|
draft = { ...draft, default: edited };
|
|
73
90
|
continue;
|
|
@@ -76,7 +93,7 @@ export async function runSettingsConsole(deps) {
|
|
|
76
93
|
const name = await pickCommand(deps, commandChoices(deps, draft));
|
|
77
94
|
if (name === undefined)
|
|
78
95
|
continue;
|
|
79
|
-
const route = await editRoute(deps, name);
|
|
96
|
+
const route = await editRoute(deps, name, currentFor(name));
|
|
80
97
|
if (route)
|
|
81
98
|
draft = { ...draft, commands: { ...draft.commands, [name]: route } };
|
|
82
99
|
continue;
|
|
@@ -90,6 +107,35 @@ export async function runSettingsConsole(deps) {
|
|
|
90
107
|
draft = { ...draft, commands: rest };
|
|
91
108
|
continue;
|
|
92
109
|
}
|
|
110
|
+
if (choice === prompts.bulkApply) {
|
|
111
|
+
// One field pass (model via the chain builder + thinking), applied to every
|
|
112
|
+
// selected command. A reference missing from the live registry warns per
|
|
113
|
+
// command but never blocks the write (OB-4 — dispatch's probe stays the
|
|
114
|
+
// authoritative availability gate).
|
|
115
|
+
const selected = await pickCommandsMulti(deps, commandChoices(deps, draft));
|
|
116
|
+
if (selected === undefined || selected.length === 0)
|
|
117
|
+
continue;
|
|
118
|
+
const route = await editRoute(deps, selected[0], currentFor(selected[0]));
|
|
119
|
+
if (route === undefined)
|
|
120
|
+
continue;
|
|
121
|
+
const nextCommands = { ...draft.commands };
|
|
122
|
+
for (const name of selected) {
|
|
123
|
+
nextCommands[name] = route;
|
|
124
|
+
warnMissingModel(deps, name, route.model ?? INHERIT);
|
|
125
|
+
}
|
|
126
|
+
draft = { ...draft, commands: nextCommands };
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (choice === prompts.bulkClear) {
|
|
130
|
+
const names = await pickCommandsMulti(deps, Object.keys(draft.commands ?? {}));
|
|
131
|
+
if (names === undefined || names.length === 0)
|
|
132
|
+
continue;
|
|
133
|
+
const rest = { ...draft.commands };
|
|
134
|
+
for (const name of names)
|
|
135
|
+
delete rest[name];
|
|
136
|
+
draft = { ...draft, commands: rest };
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
93
139
|
if (choice === prompts.policy) {
|
|
94
140
|
const picked = await deps.ui.select(prompts.policyChoice, [...UNAVAILABLE_ROUTE_POLICIES]);
|
|
95
141
|
if (isPolicy(picked))
|
|
@@ -133,18 +179,54 @@ async function openAScope(deps, globalPath, projectPath) {
|
|
|
133
179
|
}
|
|
134
180
|
}
|
|
135
181
|
/** Ask for a model and a thinking level; `undefined` means nothing changed. */
|
|
136
|
-
async function editRoute(deps, target) {
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
139
|
-
return undefined;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
182
|
+
async function editRoute(deps, target, current) {
|
|
183
|
+
const fields = await askFields(deps);
|
|
184
|
+
if (fields === undefined)
|
|
185
|
+
return undefined; // nothing marked — the route is left untouched (OB-3)
|
|
186
|
+
// Start from the value in force so only the marked field is replaced; the other
|
|
187
|
+
// keeps its merged value (AC4: changing only model asks no thinking question).
|
|
188
|
+
const route = {
|
|
189
|
+
...(current.model !== undefined ? { model: current.model } : {}),
|
|
190
|
+
...(current.thinking !== undefined ? { thinking: current.thinking } : {}),
|
|
191
|
+
};
|
|
192
|
+
if (fields.model) {
|
|
193
|
+
const model = await askModel(deps, target, current.model);
|
|
194
|
+
if (model === undefined)
|
|
195
|
+
return undefined;
|
|
196
|
+
route.model = model;
|
|
197
|
+
}
|
|
198
|
+
if (fields.thinking) {
|
|
199
|
+
const thinking = await askThinking(deps, target, current.thinking);
|
|
200
|
+
if (thinking === undefined)
|
|
201
|
+
return undefined;
|
|
202
|
+
route.thinking = thinking;
|
|
203
|
+
}
|
|
204
|
+
return route;
|
|
205
|
+
}
|
|
206
|
+
/** Which fields the operator wants to change; `undefined` means none (leave untouched). */
|
|
207
|
+
async function askFields(deps) {
|
|
208
|
+
const choice = await deps.ui.select(prompts.fields, [prompts.fieldsBoth, prompts.fieldsModel, prompts.fieldsThinking]);
|
|
209
|
+
if (choice === prompts.fieldsBoth)
|
|
210
|
+
return { model: true, thinking: true };
|
|
211
|
+
if (choice === prompts.fieldsModel)
|
|
212
|
+
return { model: true, thinking: false };
|
|
213
|
+
if (choice === prompts.fieldsThinking)
|
|
214
|
+
return { model: false, thinking: true };
|
|
215
|
+
return undefined;
|
|
144
216
|
}
|
|
145
|
-
|
|
217
|
+
/** Ask for one model entry; `undefined` means cancelled/skipped, `"inherit"` means the whole-route setting. */
|
|
218
|
+
async function pickModelEntry(deps, target, current) {
|
|
146
219
|
let answer;
|
|
147
|
-
if (deps.models && deps.models.length > 0) {
|
|
220
|
+
if (typeof deps.ui.pick === "function" && deps.models && deps.models.length > 0) {
|
|
221
|
+
// Rich seam: filterable, windowed, preselected to the value in force (OB-1).
|
|
222
|
+
const picked = await deps.ui.pick(prompts.modelPicked(target), [...deps.models, TYPED], {
|
|
223
|
+
initial: typeof current === "string" ? current : undefined,
|
|
224
|
+
});
|
|
225
|
+
answer = typeof picked === "string" ? picked : undefined;
|
|
226
|
+
if (answer === TYPED || answer === undefined)
|
|
227
|
+
answer = await deps.ui.input(prompts.model(target), "provider/modelId or inherit");
|
|
228
|
+
}
|
|
229
|
+
else if (deps.models && deps.models.length > 0) {
|
|
148
230
|
answer = await deps.ui.select(prompts.modelPicked(target), [...deps.models, TYPED]);
|
|
149
231
|
if (answer === TYPED || answer === undefined)
|
|
150
232
|
answer = await deps.ui.input(prompts.model(target), "provider/modelId or inherit");
|
|
@@ -157,17 +239,80 @@ async function askModel(deps, target) {
|
|
|
157
239
|
const value = answer.trim();
|
|
158
240
|
if (value === INHERIT)
|
|
159
241
|
return INHERIT;
|
|
160
|
-
|
|
242
|
+
const parts = parseModelReference(value);
|
|
243
|
+
if (!parts) {
|
|
161
244
|
// Rejected in the operator's terms and in the schema's: the value, and the
|
|
162
245
|
// field path the loader would name for the same mistake in a file (AC5).
|
|
163
246
|
deps.ui.notify(`Rejected: model must be "provider/modelId" or "inherit" — nothing was changed (${routePath(target)}.model).`, "error");
|
|
164
247
|
return undefined;
|
|
165
248
|
}
|
|
166
|
-
|
|
167
|
-
|
|
249
|
+
return `${parts.provider}/${parts.id}`;
|
|
250
|
+
}
|
|
251
|
+
/** Ask for a model; a lone reference is returned as-is, several references are returned as an ordered chain. */
|
|
252
|
+
async function askModel(deps, target, current) {
|
|
253
|
+
// The value in force (OB-3). For a route whose model is already a chain, seed
|
|
254
|
+
// the builder with the current references so the operator sees and edits the
|
|
255
|
+
// chain instead of rebuilding it blind (OB-17 / F2, amendment A1). Fresh and
|
|
256
|
+
// single-string editing keep the existing flow: the picker is preselected to
|
|
257
|
+
// the string value in force.
|
|
258
|
+
const seed = Array.isArray(current) ? [...current] : [];
|
|
259
|
+
if (seed.length > 0) {
|
|
260
|
+
// Already a chain: label the current chain, then let the operator append,
|
|
261
|
+
// trim, or keep it. Done with no change leaves the chain byte-identical.
|
|
262
|
+
deps.ui.notify(`Current ${target} model chain: ${seed.join(" → ")}`, "info");
|
|
263
|
+
return buildModelChain(deps, target, [...seed]);
|
|
264
|
+
}
|
|
265
|
+
const first = await pickModelEntry(deps, target, typeof current === "string" ? current : undefined);
|
|
266
|
+
if (first === undefined)
|
|
267
|
+
return undefined;
|
|
268
|
+
if (first === INHERIT)
|
|
269
|
+
return INHERIT;
|
|
270
|
+
return buildModelChain(deps, target, [first]);
|
|
168
271
|
}
|
|
169
|
-
|
|
170
|
-
|
|
272
|
+
/** Drive the ordered chain builder over the given starting chain; a single entry collapses to a bare reference. */
|
|
273
|
+
async function buildModelChain(deps, target, chain) {
|
|
274
|
+
for (;;) {
|
|
275
|
+
const atCap = chain.length >= MAX_MODEL_CHAIN;
|
|
276
|
+
if (atCap) {
|
|
277
|
+
deps.ui.notify(`A model chain is capped at ${MAX_MODEL_CHAIN} references — remove the last to make room (${routePath(target)}.model).`, "info");
|
|
278
|
+
}
|
|
279
|
+
const action = await deps.ui.select(prompts.chainAction(target), [
|
|
280
|
+
...(atCap ? [] : [prompts.chainAppend]),
|
|
281
|
+
prompts.chainRemoveLast,
|
|
282
|
+
prompts.chainDone,
|
|
283
|
+
]);
|
|
284
|
+
if (action === prompts.chainAppend) {
|
|
285
|
+
const next = await pickModelEntry(deps, target);
|
|
286
|
+
if (next === undefined)
|
|
287
|
+
continue;
|
|
288
|
+
if (next === INHERIT) {
|
|
289
|
+
deps.ui.notify(`Only "provider/modelId" references can be chain entries; "inherit" is a whole-route setting (${routePath(target)}.model).`, "error");
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
chain.push(next);
|
|
293
|
+
}
|
|
294
|
+
else if (action === prompts.chainRemoveLast) {
|
|
295
|
+
if (chain.length > 1)
|
|
296
|
+
chain.pop();
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return chain.length === 1 ? chain[0] : chain;
|
|
303
|
+
}
|
|
304
|
+
async function askThinking(deps, target, current) {
|
|
305
|
+
const options = [...THINKING_LEVELS, INHERIT];
|
|
306
|
+
let answer;
|
|
307
|
+
if (typeof deps.ui.pick === "function") {
|
|
308
|
+
const picked = await deps.ui.pick(prompts.thinking(target), options, {
|
|
309
|
+
initial: typeof current === "string" ? current : undefined,
|
|
310
|
+
});
|
|
311
|
+
answer = typeof picked === "string" ? picked : undefined;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
answer = await deps.ui.select(prompts.thinking(target), options);
|
|
315
|
+
}
|
|
171
316
|
if (answer === undefined)
|
|
172
317
|
return undefined;
|
|
173
318
|
if (answer === INHERIT || isThinkingLevel(answer))
|
|
@@ -182,6 +327,45 @@ async function pickCommand(deps, options) {
|
|
|
182
327
|
}
|
|
183
328
|
return deps.ui.select(prompts.command, [...options].sort((a, b) => a.localeCompare(b)));
|
|
184
329
|
}
|
|
330
|
+
/** Multi-select command picker over the seam's `multiple` mode; a non-rich UI falls back to repeated single selects. */
|
|
331
|
+
async function pickCommandsMulti(deps, options) {
|
|
332
|
+
if (options.length === 0) {
|
|
333
|
+
deps.ui.notify("There is no command to pick here.", "warning");
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
const sorted = [...options].sort((a, b) => a.localeCompare(b));
|
|
337
|
+
if (typeof deps.ui.pick === "function") {
|
|
338
|
+
const picked = await deps.ui.pick(prompts.command, sorted, { multiple: true });
|
|
339
|
+
if (picked === undefined)
|
|
340
|
+
return undefined;
|
|
341
|
+
return typeof picked === "string" ? [picked] : picked;
|
|
342
|
+
}
|
|
343
|
+
// Non-rich fallback: repeatedly select one candidate until the operator stops.
|
|
344
|
+
const chosen = [];
|
|
345
|
+
for (;;) {
|
|
346
|
+
const remaining = sorted.filter((option) => !chosen.includes(option));
|
|
347
|
+
if (remaining.length === 0)
|
|
348
|
+
break;
|
|
349
|
+
const picked = await deps.ui.select(prompts.command, remaining);
|
|
350
|
+
if (picked === undefined)
|
|
351
|
+
break;
|
|
352
|
+
chosen.push(picked);
|
|
353
|
+
if (!(await deps.ui.confirm(prompts.addAnother, `Picked ${chosen.join(", ")}.`)))
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
return chosen.length > 0 ? chosen : undefined;
|
|
357
|
+
}
|
|
358
|
+
/** Advisory per-command warning when a chosen reference is absent from the live registry (OB-4). */
|
|
359
|
+
function warnMissingModel(deps, target, model) {
|
|
360
|
+
if (!deps.models || deps.models.length === 0)
|
|
361
|
+
return;
|
|
362
|
+
const refs = typeof model === "string" ? [model] : model;
|
|
363
|
+
for (const ref of refs) {
|
|
364
|
+
if (ref !== INHERIT && !deps.models.includes(ref)) {
|
|
365
|
+
deps.ui.notify(`/${target}: ${ref} is not in the live model registry — dispatch will decide availability when the route runs.`, "warning");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
185
369
|
function commandChoices(deps, draft) {
|
|
186
370
|
return [...new Set([...deps.commands, ...Object.keys(draft.commands ?? {})])];
|
|
187
371
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Component, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui";
|
|
2
|
+
/** Default window height for the SelectList (the built-in position indicator scales to the list). */
|
|
3
|
+
export declare const PICKER_MAX_VISIBLE = 10;
|
|
4
|
+
/**
|
|
5
|
+
* Token/subsequence, slash-aware reference filter (OB-2).
|
|
6
|
+
*
|
|
7
|
+
* - An empty query returns every reference.
|
|
8
|
+
* - The query is split on whitespace; every token must match for a reference to
|
|
9
|
+
* survive.
|
|
10
|
+
* - A token matched as a *subsequence* (characters in order, not necessarily
|
|
11
|
+
* contiguous): `flash` matches any reference containing `flash`, `a/c`
|
|
12
|
+
* matches `anthropic/claude`.
|
|
13
|
+
* - A token ending in `/` constrains to the provider: `nan/` matches provider
|
|
14
|
+
* `nan` only, never a model id that merely contains `nan`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function filterReferences(query: string, refs: readonly string[]): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Wrap a `SelectList` as a pi-tui `Component`, rebuilding it client-side from
|
|
19
|
+
* `filterReferences` as the operator types so the subsequence/slash-aware
|
|
20
|
+
* filter (OB-2) — not `SelectList`'s own prefix `setFilter` — drives what the
|
|
21
|
+
* window shows. Control keys (arrows, Enter, Escape) forward to the list.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createPickerComponent(options: {
|
|
24
|
+
items: readonly SelectItem[];
|
|
25
|
+
maxVisible: number;
|
|
26
|
+
theme: SelectListTheme;
|
|
27
|
+
onSelect: (value: string) => void;
|
|
28
|
+
onCancel?: () => void;
|
|
29
|
+
}): Component & {
|
|
30
|
+
dispose?(): void;
|
|
31
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// src/settings/picker.ts
|
|
2
|
+
// The searchable windowed picker primitive (OB-1, OB-2, OB-12, PE-004).
|
|
3
|
+
//
|
|
4
|
+
// Two concerns live here on purpose:
|
|
5
|
+
// - `filterReferences` is the pure, session-free subsequence/slash-aware filter
|
|
6
|
+
// (OB-2). It is unit-tested in `test/picker-filter.test.mjs`.
|
|
7
|
+
// - `createPickerComponent` wraps `@earendil-works/pi-tui`'s `SelectList` as a
|
|
8
|
+
// `Component` so the adapter (extension/index.ts) can drive it through
|
|
9
|
+
// `ctx.ui.custom()` — windowed rendering with a position indicator, typing
|
|
10
|
+
// to narrow the list, selection, and cancel. The Pi-free `SettingsUi.pick`
|
|
11
|
+
// seam stays separated so the filter and the component are exercisable
|
|
12
|
+
// without a live session.
|
|
13
|
+
//
|
|
14
|
+
// The filter is implemented in this package (not imported from Pi): PE-004
|
|
15
|
+
// confirmed pi-coding-agent's `fuzzyFilter` is internal, so the same
|
|
16
|
+
// token/subsequence, slash-aware semantics the acceptance criteria pin live here.
|
|
17
|
+
import { SelectList } from "@earendil-works/pi-tui";
|
|
18
|
+
/** Default window height for the SelectList (the built-in position indicator scales to the list). */
|
|
19
|
+
export const PICKER_MAX_VISIBLE = 10;
|
|
20
|
+
/**
|
|
21
|
+
* Token/subsequence, slash-aware reference filter (OB-2).
|
|
22
|
+
*
|
|
23
|
+
* - An empty query returns every reference.
|
|
24
|
+
* - The query is split on whitespace; every token must match for a reference to
|
|
25
|
+
* survive.
|
|
26
|
+
* - A token matched as a *subsequence* (characters in order, not necessarily
|
|
27
|
+
* contiguous): `flash` matches any reference containing `flash`, `a/c`
|
|
28
|
+
* matches `anthropic/claude`.
|
|
29
|
+
* - A token ending in `/` constrains to the provider: `nan/` matches provider
|
|
30
|
+
* `nan` only, never a model id that merely contains `nan`.
|
|
31
|
+
*/
|
|
32
|
+
export function filterReferences(query, refs) {
|
|
33
|
+
const tokens = query.trim().split(/\s+/u).filter((token) => token !== "");
|
|
34
|
+
if (tokens.length === 0)
|
|
35
|
+
return [...refs];
|
|
36
|
+
return refs.filter((ref) => tokens.every((token) => matchesToken(token, ref)));
|
|
37
|
+
}
|
|
38
|
+
function matchesToken(token, ref) {
|
|
39
|
+
// Slash-aware: a trailing slash makes the token a provider prefix.
|
|
40
|
+
if (token.endsWith("/")) {
|
|
41
|
+
const provider = token.slice(0, -1);
|
|
42
|
+
if (provider === "")
|
|
43
|
+
return true;
|
|
44
|
+
const refProvider = ref.split("/")[0] ?? "";
|
|
45
|
+
return refProvider.startsWith(provider);
|
|
46
|
+
}
|
|
47
|
+
return isSubsequence(token, ref);
|
|
48
|
+
}
|
|
49
|
+
/** True when `needle` appears in `haystack` with its characters in order. */
|
|
50
|
+
function isSubsequence(needle, haystack) {
|
|
51
|
+
let i = 0;
|
|
52
|
+
for (let j = 0; j < haystack.length && i < needle.length; j += 1) {
|
|
53
|
+
if (haystack.charCodeAt(j) === needle.charCodeAt(i))
|
|
54
|
+
i += 1;
|
|
55
|
+
}
|
|
56
|
+
return i === needle.length;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Wrap a `SelectList` as a pi-tui `Component`, rebuilding it client-side from
|
|
60
|
+
* `filterReferences` as the operator types so the subsequence/slash-aware
|
|
61
|
+
* filter (OB-2) — not `SelectList`'s own prefix `setFilter` — drives what the
|
|
62
|
+
* window shows. Control keys (arrows, Enter, Escape) forward to the list.
|
|
63
|
+
*/
|
|
64
|
+
export function createPickerComponent(options) {
|
|
65
|
+
const { items, maxVisible, theme, onSelect, onCancel } = options;
|
|
66
|
+
let filterText = "";
|
|
67
|
+
const byValue = new Map(items.map((item) => [item.value, item]));
|
|
68
|
+
const buildList = () => {
|
|
69
|
+
const visible = filterReferences(filterText, items.map((item) => item.value)).map((value) => byValue.get(value) ?? { value, label: value });
|
|
70
|
+
const list = new SelectList(visible, maxVisible, theme);
|
|
71
|
+
list.onSelect = (item) => onSelect(item.value);
|
|
72
|
+
list.onCancel = onCancel;
|
|
73
|
+
return list;
|
|
74
|
+
};
|
|
75
|
+
let list = buildList();
|
|
76
|
+
return {
|
|
77
|
+
render: (width) => list.render(width),
|
|
78
|
+
invalidate: () => list.invalidate(),
|
|
79
|
+
handleInput(data) {
|
|
80
|
+
// Enter / Ctrl+C / ANSI sequences (arrows) go to the SelectList; any other
|
|
81
|
+
// single character narrows the in-package filter and rebuilds the window.
|
|
82
|
+
if (data === "\r" || data === "\u0003" || data.startsWith("\u001b") || data.length > 1) {
|
|
83
|
+
list.handleInput(data);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
filterText += data;
|
|
87
|
+
list = buildList();
|
|
88
|
+
},
|
|
89
|
+
dispose: () => {
|
|
90
|
+
// The SelectList holds no resources beyond the rendered lines.
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/settings/view.js
CHANGED
|
@@ -34,7 +34,10 @@ function configLines(config, commands) {
|
|
|
34
34
|
];
|
|
35
35
|
}
|
|
36
36
|
function route(value) {
|
|
37
|
-
|
|
37
|
+
// A chain renders its references in order, with the fallback arrow the spec
|
|
38
|
+
// names (OB-14); a single reference and `inherit` keep their existing render.
|
|
39
|
+
const model = Array.isArray(value.model) ? value.model.join(" → ") : value.model;
|
|
40
|
+
return `${model} / ${value.thinking}`;
|
|
38
41
|
}
|
|
39
42
|
function problems(list) {
|
|
40
43
|
if (list.length === 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gtrabanco/pi-agentic-workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Pi package: canonical agentic-workflow skills, friendly slash commands, and per-command model routing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@earendil-works/pi-coding-agent": ">=0.85.1"
|
|
56
56
|
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@earendil-works/pi-tui": "0.85.1"
|
|
59
|
+
},
|
|
57
60
|
"devDependencies": {
|
|
58
61
|
"@types/node": "24.13.3",
|
|
59
62
|
"typescript": "6"
|