@gtrabanco/pi-agentic-workflow 0.7.2 → 0.9.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 +48 -12
- package/README.md +47 -11
- package/dist/config/defaults.js +1 -0
- package/dist/config/merge.js +1 -0
- package/dist/config/schema.js +48 -3
- package/dist/config/types.d.ts +21 -1
- package/dist/config/types.js +3 -0
- package/dist/extension/factory.js +26 -15
- package/dist/extension/index.js +44 -1
- package/dist/routing/dispatch.js +63 -5
- package/dist/routing/types.d.ts +15 -0
- package/dist/routing/types.js +2 -0
- package/dist/settings/console.d.ts +13 -0
- package/dist/settings/console.js +218 -19
- package/dist/settings/picker.d.ts +31 -0
- package/dist/settings/picker.js +93 -0
- package/dist/settings/view.js +5 -1
- package/package.json +4 -1
package/README.es.md
CHANGED
|
@@ -74,10 +74,11 @@ 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
|
-
"onUnavailableRoute": "stop"
|
|
80
|
+
"onUnavailableRoute": "stop",
|
|
81
|
+
"onSettle": "keep"
|
|
81
82
|
}
|
|
82
83
|
```
|
|
83
84
|
|
|
@@ -87,10 +88,18 @@ de arriba usa el modelo del default con thinking `max`; todo lo demás corre con
|
|
|
87
88
|
que ya tuviera la sesión, porque la ruta por defecto del paquete es
|
|
88
89
|
`{"model": "inherit", "thinking": "inherit"}`.
|
|
89
90
|
|
|
90
|
-
- `model`
|
|
91
|
-
`/model` —
|
|
91
|
+
- `model` puede ser `provider/modelId` — la referencia exacta que muestra
|
|
92
|
+
`/model` — `"inherit"`, o un **array** ordenado de 1–4 referencias (una cadena
|
|
93
|
+
de respaldo). Para una cadena, el dispatch prueba cada referencia en orden y
|
|
94
|
+
aplica la primera que se resuelve y tiene credenciales, sin tocar la sesión
|
|
95
|
+
mientras prueba; cuando todas las entradas son inservibles el comando se
|
|
96
|
+
detiene (o, con `onUnavailableRoute` en `inherit`, avisa y corre con el modelo
|
|
97
|
+
actual), nombrando cada candidato y por qué se saltó. `plan-feature` de arriba
|
|
98
|
+
prueba `anthropic/claude-sonnet-4-5` primero y luego `openai/gpt-5.2`.
|
|
92
99
|
- `thinking` es uno de `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`,
|
|
93
100
|
o `"inherit"`.
|
|
101
|
+
- `` `onSettle` `` es uno de `"keep"` (el default) o `"restore"` — ver
|
|
102
|
+
[Tu sesión después de un comando](#tu-sesión-después-de-un-comando).
|
|
94
103
|
- Claves desconocidas, `null` y referencias mal formadas se **rechazan**, no se
|
|
95
104
|
ignoran: un error tipográfico que no hace nada en silencio es el bug que nunca
|
|
96
105
|
encuentras.
|
|
@@ -110,14 +119,28 @@ aun así:
|
|
|
110
119
|
{ "onUnavailableRoute": "inherit" }
|
|
111
120
|
```
|
|
112
121
|
|
|
113
|
-
## Tu sesión
|
|
122
|
+
## Tu sesión después de un comando
|
|
114
123
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
Por defecto (**`onSettle: "keep"`**), el modelo enrutado y el nivel de thinking
|
|
125
|
+
**se quedan** en la ventana de chat abierta cuando el comando termina. Si
|
|
126
|
+
`plan-feature` corre en `glm-5` y quieres retocar el plan que produjo, tu siguiente
|
|
127
|
+
prompt sigue corriendo en `glm-5` — igual con una pregunta posterior. Cuando no
|
|
128
|
+
necesitas el modelo pesado, lo cambias tú con `/model` (o Ctrl+P) a algo más
|
|
129
|
+
barato; nada restaura sobre tu elección.
|
|
130
|
+
|
|
131
|
+
Para volver a traer el modelo y el nivel de thinking previos al comando cuando este
|
|
132
|
+
termina, pon:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{ "onSettle": "restore" }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
(El modo `restore` es el histórico contrato AC8: tras un comando enrutado la
|
|
139
|
+
sesión vuelve a como estaba — el modelo *y* el nivel de thinking, porque
|
|
140
|
+
seleccionar un modelo puede mover el nivel. Si cambias el modelo tú mismo a
|
|
141
|
+
mitad de turno, con `/model` por ejemplo, no se restaura nada: tu elección gana,
|
|
142
|
+
y el comando lo dice. Si mueves solo el nivel de thinking, lo conservas mientras
|
|
143
|
+
el modelo vuelve.)
|
|
121
144
|
|
|
122
145
|
## Consola de configuración
|
|
123
146
|
|
|
@@ -125,11 +148,24 @@ vuelve.
|
|
|
125
148
|
/agentic-workflow-settings
|
|
126
149
|
```
|
|
127
150
|
|
|
151
|
+
`/aw-settings` es un atajo para la misma consola.
|
|
152
|
+
|
|
128
153
|
Muestra en qué corre cada comando ahora mismo, y qué archivo se niega a parsearse,
|
|
129
154
|
y luego deja editar **un archivo a la vez** y guardar en alcance global o de
|
|
130
155
|
proyecto. No guarda encima de un archivo que no sabe leer, y no toca el
|
|
131
156
|
archivo de proyecto mientras el proyecto no sea de confianza.
|
|
132
157
|
|
|
158
|
+
Los selectores de modelo y thinking de la consola son buscables y con ventana:
|
|
159
|
+
escribir estrecha la lista, el cursor se queda en pantalla, un indicador de
|
|
160
|
+
posición muestra dónde estás y el valor en vigor viene preseleccionado y
|
|
161
|
+
etiquetado `(current)` / `(default route)`. Una edición de ruta pregunta qué
|
|
162
|
+
campo cambiar (model, thinking), así que guardar sin cambios deja el archivo
|
|
163
|
+
byte a byte idéntico; el campo de modelo puede construir una cadena de respaldo
|
|
164
|
+
ordenada (`a/m1 → b/m2`); y una sola pasada puede aplicar o limpiar una ruta en
|
|
165
|
+
varios comandos, avisando por comando cuando una referencia falta del registro
|
|
166
|
+
vivo. Fuera de una sesión TUI los selectores caen a un prompt simple, así que la
|
|
167
|
+
consola nunca se queda sin salida.
|
|
168
|
+
|
|
133
169
|
## Diagnóstico
|
|
134
170
|
|
|
135
171
|
| Ves | Significa |
|
|
@@ -140,7 +176,7 @@ archivo de proyecto mientras el proyecto no sea de confianza.
|
|
|
140
176
|
| `could not be selected` | Pi rechazó el cambio. El comando se detiene con el motivo — salvo que `onUnavailableRoute` sea `inherit`, en cuyo caso avisa y se ejecuta con tu modelo actual. |
|
|
141
177
|
| `refused: the agent is busy` | Hay un turno en ejecución. Espera a que termine. |
|
|
142
178
|
| `is still routed` | El comando enrutado anterior no ha terminado. |
|
|
143
|
-
| `leaving the model you chose in place` | Cambiaste el modelo durante
|
|
179
|
+
| `leaving the model you chose in place` | (en modo `restore`) Cambiaste el modelo durante un turno enrutado, así que no se restauró nada: tu elección ganó. |
|
|
144
180
|
| `these configured routes match no command` | Una clave de `commands` no nombra nada. Corrige la escritura o elimina la entrada. |
|
|
145
181
|
|
|
146
182
|
## Notas
|
package/README.md
CHANGED
|
@@ -72,10 +72,11 @@ 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
|
-
"onUnavailableRoute": "stop"
|
|
78
|
+
"onUnavailableRoute": "stop",
|
|
79
|
+
"onSettle": "keep"
|
|
79
80
|
}
|
|
80
81
|
```
|
|
81
82
|
|
|
@@ -85,10 +86,18 @@ above runs on the default model with `max` thinking; anything else runs on
|
|
|
85
86
|
whatever the session already had, because the shipped default route is
|
|
86
87
|
`{"model": "inherit", "thinking": "inherit"}`.
|
|
87
88
|
|
|
88
|
-
- `model`
|
|
89
|
-
`"inherit"
|
|
89
|
+
- `model` can be `provider/modelId` — the exact reference `/model` shows —
|
|
90
|
+
`"inherit"`, or an ordered **array** of 1–4 references (a fallback chain). For
|
|
91
|
+
a chain, dispatch probes each reference in order and applies the first one that
|
|
92
|
+
resolves and has credentials, without touching the session while probing; when
|
|
93
|
+
every entry is unusable the command stops (or, with `onUnavailableRoute`
|
|
94
|
+
`inherit`, warns and runs on the current model), naming each candidate and why
|
|
95
|
+
it was skipped. `plan-feature` above tries `anthropic/claude-sonnet-4-5` first,
|
|
96
|
+
then `openai/gpt-5.2`.
|
|
90
97
|
- `thinking` is one of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`,
|
|
91
98
|
or `"inherit"`.
|
|
99
|
+
- `` `onSettle` `` is one of `"keep"` (the default) or `"restore"` — see
|
|
100
|
+
[Your session after a command](#your-session-after-a-command).
|
|
92
101
|
- Unknown keys, `null`s and malformed references are **rejected**, not ignored: a
|
|
93
102
|
typo that silently did nothing is the bug you would never find.
|
|
94
103
|
|
|
@@ -106,13 +115,28 @@ nothing runs on a model you did not pick. To run anyway on the current model, se
|
|
|
106
115
|
{ "onUnavailableRoute": "inherit" }
|
|
107
116
|
```
|
|
108
117
|
|
|
109
|
-
## Your session
|
|
118
|
+
## Your session after a command
|
|
110
119
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
By default (**`onSettle: "keep"`**), the routed model and thinking level **stay**
|
|
121
|
+
in the open chat window once the command settles. If `plan-feature` runs on
|
|
122
|
+
`glm-5` and you want to tweak the plan it produced, your next prompt keeps
|
|
123
|
+
running on `glm-5` — same for a follow-up question. When you don't need the
|
|
124
|
+
heavy model, switch it yourself with `/model` (or Ctrl+P) to something cheaper;
|
|
125
|
+
nothing restores over your choice.
|
|
126
|
+
|
|
127
|
+
To bring back the pre-command model and thinking level after a command settles,
|
|
128
|
+
set:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{ "onSettle": "restore" }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
(The `restore` mode is the historical AC8 contract: after a routed command the
|
|
135
|
+
session is put back the way you had it — the model *and* the thinking level,
|
|
136
|
+
because selecting a model can move the level. If you change the model yourself
|
|
137
|
+
mid-turn, with `/model` say, nothing is restored: your choice wins, and the
|
|
138
|
+
command says so. Change only the thinking level and you keep it while the model
|
|
139
|
+
still comes back.)
|
|
116
140
|
|
|
117
141
|
## Settings console
|
|
118
142
|
|
|
@@ -120,10 +144,22 @@ thinking level and you keep it while the model still comes back.
|
|
|
120
144
|
/agentic-workflow-settings
|
|
121
145
|
```
|
|
122
146
|
|
|
147
|
+
`/aw-settings` is a shorthand for the same console.
|
|
148
|
+
|
|
123
149
|
Shows what each command runs on right now, and which file is refusing to parse,
|
|
124
150
|
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
151
|
touch the project file while the project is untrusted.
|
|
126
152
|
|
|
153
|
+
The console's model and thinking pickers are searchable and windowed: typing
|
|
154
|
+
narrows the list, the cursor stays on screen, a position indicator shows where
|
|
155
|
+
you are, and the value currently in force is pre-selected and labelled
|
|
156
|
+
`(current)` / `(default route)`. A route edit asks which field to change
|
|
157
|
+
(model, thinking), so a no-change save leaves the file byte-identical; the
|
|
158
|
+
model field can build an ordered fallback chain (`a/m1 → b/m2`); and one pass
|
|
159
|
+
can apply or clear a route across several commands, warning per command when a
|
|
160
|
+
reference is missing from the live registry. Outside a TUI session the pickers
|
|
161
|
+
fall back to a plain prompt, so the console never dead-ends.
|
|
162
|
+
|
|
127
163
|
## Troubleshooting
|
|
128
164
|
|
|
129
165
|
| You see | Means |
|
|
@@ -134,7 +170,7 @@ touch the project file while the project is untrusted.
|
|
|
134
170
|
| `could not be selected` | Pi refused the switch. The command stops with the reason — unless `onUnavailableRoute` is `inherit`, in which case it warns and runs on your current model. |
|
|
135
171
|
| `refused: the agent is busy` | A turn is running. Wait for it to settle. |
|
|
136
172
|
| `is still routed` | The previous routed command has not settled yet. |
|
|
137
|
-
| `leaving the model you chose in place` | You changed the model during a routed turn, so nothing was restored — your choice won. |
|
|
173
|
+
| `leaving the model you chose in place` | (in `restore` mode) You changed the model during a routed turn, so nothing was restored — your choice won. |
|
|
138
174
|
| `these configured routes match no command` | A `commands` key names nothing. Fix the spelling or delete the entry. |
|
|
139
175
|
|
|
140
176
|
## Notes
|
package/dist/config/defaults.js
CHANGED
package/dist/config/merge.js
CHANGED
|
@@ -35,6 +35,7 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
|
|
|
35
35
|
default: effectiveDefault,
|
|
36
36
|
commands,
|
|
37
37
|
onUnavailableRoute: projectFile.onUnavailableRoute ?? globalFile.onUnavailableRoute ?? DEFAULT_CONFIG.onUnavailableRoute,
|
|
38
|
+
onSettle: projectFile.onSettle ?? globalFile.onSettle ?? DEFAULT_CONFIG.onSettle,
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
/** The route a command runs under: its own resolved override, else the default route. */
|
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, SETTLE_POLICIES, 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
|
*
|
|
@@ -8,7 +8,7 @@ import { THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "./types.js";
|
|
|
8
8
|
* strong model ran when it did not. Only a *missing* file resolves to the
|
|
9
9
|
* default, and that decision belongs to the loader.
|
|
10
10
|
*/
|
|
11
|
-
const ROOT_KEYS = new Set(["default", "commands", "onUnavailableRoute"]);
|
|
11
|
+
const ROOT_KEYS = new Set(["default", "commands", "onUnavailableRoute", "onSettle"]);
|
|
12
12
|
const ROUTE_KEYS = new Set(["model", "thinking"]);
|
|
13
13
|
const COMMAND_NAME = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
14
14
|
function isRecord(value) {
|
|
@@ -37,6 +37,9 @@ function isThinkingSetting(value) {
|
|
|
37
37
|
function isUnavailableRoutePolicy(value) {
|
|
38
38
|
return UNAVAILABLE_ROUTE_POLICIES.includes(value);
|
|
39
39
|
}
|
|
40
|
+
function isSettlePolicy(value) {
|
|
41
|
+
return SETTLE_POLICIES.includes(value);
|
|
42
|
+
}
|
|
40
43
|
function describe(value) {
|
|
41
44
|
return typeof value === "string" ? `"${value}"` : JSON.stringify(value) ?? String(value);
|
|
42
45
|
}
|
|
@@ -52,6 +55,37 @@ function checkRoute(value, path, issues) {
|
|
|
52
55
|
continue;
|
|
53
56
|
}
|
|
54
57
|
if (key === "model") {
|
|
58
|
+
if (Array.isArray(entry)) {
|
|
59
|
+
// A chain must be non-empty, hold only references, and stay within the cap.
|
|
60
|
+
if (entry.length === 0) {
|
|
61
|
+
issues.push({
|
|
62
|
+
path: `${path}.model`,
|
|
63
|
+
message: `must be "inherit", "provider/modelId", or a non-empty chain of references, got an empty array`,
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (entry.length > MAX_MODEL_CHAIN) {
|
|
68
|
+
issues.push({
|
|
69
|
+
path: `${path}.model`,
|
|
70
|
+
message: `chain holds ${entry.length} entries; ${MAX_MODEL_CHAIN} is the maximum`,
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
let chainValid = true;
|
|
75
|
+
for (let index = 0; index < entry.length; index += 1) {
|
|
76
|
+
if (!isModelReference(entry[index])) {
|
|
77
|
+
issues.push({
|
|
78
|
+
path: `${path}.model`,
|
|
79
|
+
message: `chain entry ${index + 1} (${describe(entry[index])}) must be "provider/modelId"`,
|
|
80
|
+
});
|
|
81
|
+
chainValid = false;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (chainValid)
|
|
86
|
+
route.model = entry;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
55
89
|
if (entry !== "inherit" && !isModelReference(entry)) {
|
|
56
90
|
issues.push({
|
|
57
91
|
path: `${path}.model`,
|
|
@@ -84,7 +118,7 @@ function validateConfig(value) {
|
|
|
84
118
|
if (!ROOT_KEYS.has(key)) {
|
|
85
119
|
issues.push({
|
|
86
120
|
path: `$.${displayKey(key)}`,
|
|
87
|
-
message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute)`,
|
|
121
|
+
message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle)`,
|
|
88
122
|
});
|
|
89
123
|
}
|
|
90
124
|
}
|
|
@@ -122,6 +156,17 @@ function validateConfig(value) {
|
|
|
122
156
|
config.onUnavailableRoute = value.onUnavailableRoute;
|
|
123
157
|
}
|
|
124
158
|
}
|
|
159
|
+
if (value.onSettle !== undefined) {
|
|
160
|
+
if (!isSettlePolicy(value.onSettle)) {
|
|
161
|
+
issues.push({
|
|
162
|
+
path: "$.onSettle",
|
|
163
|
+
message: `must be "keep" or "restore", got ${describe(value.onSettle)}`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
config.onSettle = value.onSettle;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
125
170
|
return issues.length > 0 ? { ok: false, issues } : { ok: true, config };
|
|
126
171
|
}
|
|
127
172
|
/**
|
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;
|
|
@@ -28,18 +37,29 @@ export interface ConfigFile {
|
|
|
28
37
|
default?: RouteFile;
|
|
29
38
|
commands?: Record<string, RouteFile>;
|
|
30
39
|
onUnavailableRoute?: UnavailableRoutePolicy;
|
|
40
|
+
/** What happens to the routed model and thinking level after the command settles. */
|
|
41
|
+
onSettle?: SettlePolicy;
|
|
31
42
|
}
|
|
32
43
|
export type UnavailableRoutePolicy = "stop" | "inherit";
|
|
44
|
+
/**
|
|
45
|
+
* What the session holds after a routed command settles.
|
|
46
|
+
* - `"keep"` — the routed model and thinking level stay in the open chat window,
|
|
47
|
+
* so a follow-up edit or question keeps running on the model that planned it.
|
|
48
|
+
* - `"restore"` — the pre-dispatch model and thinking level come back (AC8).
|
|
49
|
+
*/
|
|
50
|
+
export type SettlePolicy = "keep" | "restore";
|
|
33
51
|
/** One rejected field inside one config file, addressed by a JSON-path-ish string. */
|
|
34
52
|
export interface ConfigIssue {
|
|
35
53
|
path: string;
|
|
36
54
|
message: string;
|
|
37
55
|
}
|
|
38
56
|
export declare const UNAVAILABLE_ROUTE_POLICIES: readonly UnavailableRoutePolicy[];
|
|
57
|
+
export declare const SETTLE_POLICIES: readonly SettlePolicy[];
|
|
39
58
|
export interface EffectiveConfig {
|
|
40
59
|
default: Route;
|
|
41
60
|
commands: Record<string, Route>;
|
|
42
61
|
onUnavailableRoute: UnavailableRoutePolicy;
|
|
62
|
+
onSettle: SettlePolicy;
|
|
43
63
|
}
|
|
44
64
|
export interface ConfigProblem {
|
|
45
65
|
scope: "global" | "project";
|
package/dist/config/types.js
CHANGED
|
@@ -9,4 +9,7 @@
|
|
|
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"];
|
|
15
|
+
export const SETTLE_POLICIES = ["keep", "restore"];
|
|
@@ -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
|
@@ -79,6 +79,12 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
|
|
|
79
79
|
// Cleared first: the restore fires its own select events, and a turn that is
|
|
80
80
|
// already over must not read them as operator changes.
|
|
81
81
|
pending = undefined;
|
|
82
|
+
// `keep`: leave the routed model and thinking level in the open chat window,
|
|
83
|
+
// so a follow-up edit or question keeps running on the model that planned it.
|
|
84
|
+
// The operator's own mid-turn choice is still untouched (nothing is restored,
|
|
85
|
+
// so it cannot be overwritten). Only "restore" puts the snapshot back (AC8).
|
|
86
|
+
if (turn.settlePolicy === "keep")
|
|
87
|
+
return;
|
|
82
88
|
await restore(turn, surface(ctx), ctx, "finished");
|
|
83
89
|
},
|
|
84
90
|
async dispatch(command, args, ctx) {
|
|
@@ -111,7 +117,13 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
|
|
|
111
117
|
const session = surface(ctx);
|
|
112
118
|
const route = effectiveRoute(loaded.config, command.name);
|
|
113
119
|
let target;
|
|
114
|
-
|
|
120
|
+
/** The reference string behind `target`, for the select-failure message (a chain names the chosen entry). */
|
|
121
|
+
let chosenRef;
|
|
122
|
+
if (route.model === "inherit") {
|
|
123
|
+
// inherit: no probe — run on the session model (OB-9).
|
|
124
|
+
}
|
|
125
|
+
else if (typeof route.model === "string") {
|
|
126
|
+
// Single reference: behaviour and message shape unchanged (OB-9).
|
|
115
127
|
const reference = parseModelReference(route.model);
|
|
116
128
|
const found = reference ? ctx.find(reference.provider, reference.id) : undefined;
|
|
117
129
|
const blocker = !found
|
|
@@ -127,6 +139,36 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
|
|
|
127
139
|
}
|
|
128
140
|
else {
|
|
129
141
|
target = found;
|
|
142
|
+
chosenRef = route.model;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
// Chain: probe entries in order with only ctx.find + hasConfiguredAuth
|
|
147
|
+
// (no session mutation), collecting one skip reason per entry, and apply
|
|
148
|
+
// the first usable one (OB-8, OB-9, OB-10).
|
|
149
|
+
const reasons = [];
|
|
150
|
+
for (const reference of route.model) {
|
|
151
|
+
const parsed = parseModelReference(reference);
|
|
152
|
+
const found = parsed ? ctx.find(parsed.provider, parsed.id) : undefined;
|
|
153
|
+
if (!found) {
|
|
154
|
+
reasons.push(`${reference} is not in the model registry`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (!ctx.hasConfiguredAuth(found)) {
|
|
158
|
+
reasons.push(`${reference} has no configured credentials`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
target = found;
|
|
162
|
+
chosenRef = reference;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
if (!target) {
|
|
166
|
+
const tried = route.model.join(", ");
|
|
167
|
+
const detail = reasons.join("; ");
|
|
168
|
+
if (loaded.config.onUnavailableRoute !== "inherit") {
|
|
169
|
+
return refuse(ctx, "unavailable-route", `/${command.name} stopped: the configured model chain ${tried} is unavailable (${detail}). ${configureHint}`);
|
|
170
|
+
}
|
|
171
|
+
ctx.notify(`/${command.name}: the configured model chain ${tried} is unavailable (${detail}), so it runs on the current session model. ${configureHint}`, "warning");
|
|
130
172
|
}
|
|
131
173
|
}
|
|
132
174
|
const snapshot = { model: ctx.model, thinking: session.getThinkingLevel() };
|
|
@@ -135,9 +177,9 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
|
|
|
135
177
|
const selected = await session.setModel(target);
|
|
136
178
|
if (!selected) {
|
|
137
179
|
if (loaded.config.onUnavailableRoute !== "inherit") {
|
|
138
|
-
return refuse(ctx, "unavailable-route", `/${command.name} stopped: ${
|
|
180
|
+
return refuse(ctx, "unavailable-route", `/${command.name} stopped: ${chosenRef} could not be selected. ${configureHint}`);
|
|
139
181
|
}
|
|
140
|
-
ctx.notify(`/${command.name}: ${
|
|
182
|
+
ctx.notify(`/${command.name}: ${chosenRef} could not be selected, so it runs on the current session model. ${configureHint}`, "warning");
|
|
141
183
|
}
|
|
142
184
|
else {
|
|
143
185
|
applied.model = target;
|
|
@@ -162,7 +204,14 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
|
|
|
162
204
|
ctx.notify(`/${command.name} is running. Per-command models are optional: configure them with /${settingsCommand} or the pi-agentic-workflow.json files. This hint appears once.`, "info");
|
|
163
205
|
}
|
|
164
206
|
if (applied.model || applied.thinking) {
|
|
165
|
-
pending = {
|
|
207
|
+
pending = {
|
|
208
|
+
command: command.name,
|
|
209
|
+
snapshot,
|
|
210
|
+
applied,
|
|
211
|
+
settlePolicy: loaded.config.onSettle,
|
|
212
|
+
userChangedModel: false,
|
|
213
|
+
userChangedThinking: false,
|
|
214
|
+
};
|
|
166
215
|
}
|
|
167
216
|
// Pi expands `/skill:<x>` by the skill's frontmatter `name:`, and passes an
|
|
168
217
|
// unknown key through as literal text — so the name is the only correct
|
|
@@ -181,7 +230,16 @@ export function createRouter({ surface, loadConfig, hint, settingsCommand, known
|
|
|
181
230
|
pending = undefined;
|
|
182
231
|
const rolledBack = Boolean(applied.model || applied.thinking);
|
|
183
232
|
if (rolledBack) {
|
|
184
|
-
await restore({
|
|
233
|
+
await restore({
|
|
234
|
+
command: command.name,
|
|
235
|
+
snapshot,
|
|
236
|
+
applied,
|
|
237
|
+
// The turn never started, so the rollback always restores — it is not a
|
|
238
|
+
// settle, and `onSettle` does not apply. Explicit, not read from config.
|
|
239
|
+
settlePolicy: "restore",
|
|
240
|
+
userChangedModel: false,
|
|
241
|
+
userChangedThinking: false,
|
|
242
|
+
}, session, ctx, `dispatch failed (${error.message}); the session was put back`);
|
|
185
243
|
}
|
|
186
244
|
return refuse(ctx, "dispatch-failed", `/${command.name} was not dispatched: ${error.message}. Nothing was sent${rolledBack ? ", and the session model was put back" : ""}.`);
|
|
187
245
|
}
|
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";
|
|
@@ -40,16 +40,29 @@ export declare const prompts: {
|
|
|
40
40
|
readonly undone: "The routing was undone and the session put back.";
|
|
41
41
|
readonly command: "Which command?";
|
|
42
42
|
readonly policyChoice: "What should happen when a configured model is unavailable?";
|
|
43
|
+
readonly settleChoice: "What should happen after a routed command settles?";
|
|
43
44
|
readonly saveTo: (path: string) => string;
|
|
44
45
|
readonly discard: "Discard the draft?";
|
|
45
46
|
readonly setDefaultRoute: "Set the default route";
|
|
46
47
|
readonly setOverride: "Set a command override";
|
|
47
48
|
readonly clearOverride: "Clear a command override";
|
|
49
|
+
readonly bulkApply: "Apply one route to several commands";
|
|
50
|
+
readonly bulkClear: "Clear several overrides";
|
|
51
|
+
readonly addAnother: "Add another?";
|
|
48
52
|
readonly policy: "Set the unavailable-route policy";
|
|
53
|
+
readonly settle: "Set the post-command model keep/restore policy";
|
|
49
54
|
readonly save: "Save";
|
|
50
55
|
readonly cancel: "Cancel";
|
|
51
56
|
readonly model: (target: string) => string;
|
|
52
57
|
readonly modelPicked: (target: string) => string;
|
|
53
58
|
readonly thinking: (target: string) => string;
|
|
59
|
+
readonly fields: "Which fields should change?";
|
|
60
|
+
readonly fieldsBoth: "model and thinking";
|
|
61
|
+
readonly fieldsModel: "model only";
|
|
62
|
+
readonly fieldsThinking: "thinking only";
|
|
63
|
+
readonly chainAction: (target: string) => string;
|
|
64
|
+
readonly chainAppend: "Add a fallback";
|
|
65
|
+
readonly chainRemoveLast: "Remove the last";
|
|
66
|
+
readonly chainDone: "Done";
|
|
54
67
|
};
|
|
55
68
|
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, SETTLE_POLICIES, 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
|
|
@@ -22,17 +23,30 @@ export const prompts = {
|
|
|
22
23
|
undone: "The routing was undone and the session put back.",
|
|
23
24
|
command: "Which command?",
|
|
24
25
|
policyChoice: "What should happen when a configured model is unavailable?",
|
|
26
|
+
settleChoice: "What should happen after a routed command settles?",
|
|
25
27
|
saveTo: (path) => `Save the draft to ${path}?`,
|
|
26
28
|
discard: "Discard the draft?",
|
|
27
29
|
setDefaultRoute: "Set the default route",
|
|
28
30
|
setOverride: "Set a command override",
|
|
29
31
|
clearOverride: "Clear a command override",
|
|
32
|
+
bulkApply: "Apply one route to several commands",
|
|
33
|
+
bulkClear: "Clear several overrides",
|
|
34
|
+
addAnother: "Add another?",
|
|
30
35
|
policy: "Set the unavailable-route policy",
|
|
36
|
+
settle: "Set the post-command model keep/restore policy",
|
|
31
37
|
save: "Save",
|
|
32
38
|
cancel: "Cancel",
|
|
33
39
|
model: (target) => `Model for ${target}?`,
|
|
34
40
|
modelPicked: (target) => `Which model for ${target}?`,
|
|
35
41
|
thinking: (target) => `Thinking level for ${target}?`,
|
|
42
|
+
fields: "Which fields should change?",
|
|
43
|
+
fieldsBoth: "model and thinking",
|
|
44
|
+
fieldsModel: "model only",
|
|
45
|
+
fieldsThinking: "thinking only",
|
|
46
|
+
chainAction: (target) => `Model chain for ${target}?`,
|
|
47
|
+
chainAppend: "Add a fallback",
|
|
48
|
+
chainRemoveLast: "Remove the last",
|
|
49
|
+
chainDone: "Done",
|
|
36
50
|
};
|
|
37
51
|
const GLOBAL_LABEL = "Global";
|
|
38
52
|
const PROJECT_LABEL = "Project";
|
|
@@ -40,7 +54,10 @@ const TYPED = "Type another reference…";
|
|
|
40
54
|
const INHERIT = "inherit";
|
|
41
55
|
export async function runSettingsConsole(deps) {
|
|
42
56
|
const paths = configFilePaths(deps.agentDir, deps.cwd);
|
|
43
|
-
|
|
57
|
+
const merged = loadConfig({ agentDir: deps.agentDir, cwd: deps.cwd, projectTrusted: deps.projectTrusted, readFile: deps.readFile });
|
|
58
|
+
deps.ui.notify(renderMergedConfig(merged, deps.commands).join("\n"), "info");
|
|
59
|
+
/** The value in force for a target (the merged route, or the default when the target is "the default route"). */
|
|
60
|
+
const currentFor = (target) => effectiveRoute(merged.config, target);
|
|
44
61
|
const opened = await openAScope(deps, paths.global, paths.project);
|
|
45
62
|
if (!opened)
|
|
46
63
|
return { status: "cancelled", edited: false };
|
|
@@ -53,7 +70,10 @@ export async function runSettingsConsole(deps) {
|
|
|
53
70
|
prompts.setDefaultRoute,
|
|
54
71
|
prompts.setOverride,
|
|
55
72
|
prompts.clearOverride,
|
|
73
|
+
prompts.bulkApply,
|
|
74
|
+
prompts.bulkClear,
|
|
56
75
|
prompts.policy,
|
|
76
|
+
prompts.settle,
|
|
57
77
|
...(deps.routing?.inFlight() ? [prompts.undoInFlight] : []),
|
|
58
78
|
prompts.save,
|
|
59
79
|
prompts.cancel,
|
|
@@ -67,7 +87,7 @@ export async function runSettingsConsole(deps) {
|
|
|
67
87
|
return { status: "cancelled", edited: dirty(draft, original) ? await discard(deps.ui) : false };
|
|
68
88
|
}
|
|
69
89
|
if (choice === prompts.setDefaultRoute) {
|
|
70
|
-
const edited = await editRoute(deps, DEFAULT_ROUTE);
|
|
90
|
+
const edited = await editRoute(deps, DEFAULT_ROUTE, currentFor(DEFAULT_ROUTE));
|
|
71
91
|
if (edited)
|
|
72
92
|
draft = { ...draft, default: edited };
|
|
73
93
|
continue;
|
|
@@ -76,7 +96,7 @@ export async function runSettingsConsole(deps) {
|
|
|
76
96
|
const name = await pickCommand(deps, commandChoices(deps, draft));
|
|
77
97
|
if (name === undefined)
|
|
78
98
|
continue;
|
|
79
|
-
const route = await editRoute(deps, name);
|
|
99
|
+
const route = await editRoute(deps, name, currentFor(name));
|
|
80
100
|
if (route)
|
|
81
101
|
draft = { ...draft, commands: { ...draft.commands, [name]: route } };
|
|
82
102
|
continue;
|
|
@@ -90,12 +110,47 @@ export async function runSettingsConsole(deps) {
|
|
|
90
110
|
draft = { ...draft, commands: rest };
|
|
91
111
|
continue;
|
|
92
112
|
}
|
|
113
|
+
if (choice === prompts.bulkApply) {
|
|
114
|
+
// One field pass (model via the chain builder + thinking), applied to every
|
|
115
|
+
// selected command. A reference missing from the live registry warns per
|
|
116
|
+
// command but never blocks the write (OB-4 — dispatch's probe stays the
|
|
117
|
+
// authoritative availability gate).
|
|
118
|
+
const selected = await pickCommandsMulti(deps, commandChoices(deps, draft));
|
|
119
|
+
if (selected === undefined || selected.length === 0)
|
|
120
|
+
continue;
|
|
121
|
+
const route = await editRoute(deps, selected[0], currentFor(selected[0]));
|
|
122
|
+
if (route === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
const nextCommands = { ...draft.commands };
|
|
125
|
+
for (const name of selected) {
|
|
126
|
+
nextCommands[name] = route;
|
|
127
|
+
warnMissingModel(deps, name, route.model ?? INHERIT);
|
|
128
|
+
}
|
|
129
|
+
draft = { ...draft, commands: nextCommands };
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (choice === prompts.bulkClear) {
|
|
133
|
+
const names = await pickCommandsMulti(deps, Object.keys(draft.commands ?? {}));
|
|
134
|
+
if (names === undefined || names.length === 0)
|
|
135
|
+
continue;
|
|
136
|
+
const rest = { ...draft.commands };
|
|
137
|
+
for (const name of names)
|
|
138
|
+
delete rest[name];
|
|
139
|
+
draft = { ...draft, commands: rest };
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
93
142
|
if (choice === prompts.policy) {
|
|
94
143
|
const picked = await deps.ui.select(prompts.policyChoice, [...UNAVAILABLE_ROUTE_POLICIES]);
|
|
95
144
|
if (isPolicy(picked))
|
|
96
145
|
draft = { ...draft, onUnavailableRoute: picked };
|
|
97
146
|
continue;
|
|
98
147
|
}
|
|
148
|
+
if (choice === prompts.settle) {
|
|
149
|
+
const picked = await deps.ui.select(prompts.settleChoice, [...SETTLE_POLICIES]);
|
|
150
|
+
if (isSettle(picked))
|
|
151
|
+
draft = { ...draft, onSettle: picked };
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
99
154
|
if (choice === prompts.save) {
|
|
100
155
|
const saved = await saveScope(deps, scope, path, draft);
|
|
101
156
|
if (saved)
|
|
@@ -133,18 +188,54 @@ async function openAScope(deps, globalPath, projectPath) {
|
|
|
133
188
|
}
|
|
134
189
|
}
|
|
135
190
|
/** 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
|
-
|
|
191
|
+
async function editRoute(deps, target, current) {
|
|
192
|
+
const fields = await askFields(deps);
|
|
193
|
+
if (fields === undefined)
|
|
194
|
+
return undefined; // nothing marked — the route is left untouched (OB-3)
|
|
195
|
+
// Start from the value in force so only the marked field is replaced; the other
|
|
196
|
+
// keeps its merged value (AC4: changing only model asks no thinking question).
|
|
197
|
+
const route = {
|
|
198
|
+
...(current.model !== undefined ? { model: current.model } : {}),
|
|
199
|
+
...(current.thinking !== undefined ? { thinking: current.thinking } : {}),
|
|
200
|
+
};
|
|
201
|
+
if (fields.model) {
|
|
202
|
+
const model = await askModel(deps, target, current.model);
|
|
203
|
+
if (model === undefined)
|
|
204
|
+
return undefined;
|
|
205
|
+
route.model = model;
|
|
206
|
+
}
|
|
207
|
+
if (fields.thinking) {
|
|
208
|
+
const thinking = await askThinking(deps, target, current.thinking);
|
|
209
|
+
if (thinking === undefined)
|
|
210
|
+
return undefined;
|
|
211
|
+
route.thinking = thinking;
|
|
212
|
+
}
|
|
213
|
+
return route;
|
|
144
214
|
}
|
|
145
|
-
|
|
215
|
+
/** Which fields the operator wants to change; `undefined` means none (leave untouched). */
|
|
216
|
+
async function askFields(deps) {
|
|
217
|
+
const choice = await deps.ui.select(prompts.fields, [prompts.fieldsBoth, prompts.fieldsModel, prompts.fieldsThinking]);
|
|
218
|
+
if (choice === prompts.fieldsBoth)
|
|
219
|
+
return { model: true, thinking: true };
|
|
220
|
+
if (choice === prompts.fieldsModel)
|
|
221
|
+
return { model: true, thinking: false };
|
|
222
|
+
if (choice === prompts.fieldsThinking)
|
|
223
|
+
return { model: false, thinking: true };
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
/** Ask for one model entry; `undefined` means cancelled/skipped, `"inherit"` means the whole-route setting. */
|
|
227
|
+
async function pickModelEntry(deps, target, current) {
|
|
146
228
|
let answer;
|
|
147
|
-
if (deps.models && deps.models.length > 0) {
|
|
229
|
+
if (typeof deps.ui.pick === "function" && deps.models && deps.models.length > 0) {
|
|
230
|
+
// Rich seam: filterable, windowed, preselected to the value in force (OB-1).
|
|
231
|
+
const picked = await deps.ui.pick(prompts.modelPicked(target), [...deps.models, TYPED], {
|
|
232
|
+
initial: typeof current === "string" ? current : undefined,
|
|
233
|
+
});
|
|
234
|
+
answer = typeof picked === "string" ? picked : undefined;
|
|
235
|
+
if (answer === TYPED || answer === undefined)
|
|
236
|
+
answer = await deps.ui.input(prompts.model(target), "provider/modelId or inherit");
|
|
237
|
+
}
|
|
238
|
+
else if (deps.models && deps.models.length > 0) {
|
|
148
239
|
answer = await deps.ui.select(prompts.modelPicked(target), [...deps.models, TYPED]);
|
|
149
240
|
if (answer === TYPED || answer === undefined)
|
|
150
241
|
answer = await deps.ui.input(prompts.model(target), "provider/modelId or inherit");
|
|
@@ -157,17 +248,80 @@ async function askModel(deps, target) {
|
|
|
157
248
|
const value = answer.trim();
|
|
158
249
|
if (value === INHERIT)
|
|
159
250
|
return INHERIT;
|
|
160
|
-
|
|
251
|
+
const parts = parseModelReference(value);
|
|
252
|
+
if (!parts) {
|
|
161
253
|
// Rejected in the operator's terms and in the schema's: the value, and the
|
|
162
254
|
// field path the loader would name for the same mistake in a file (AC5).
|
|
163
255
|
deps.ui.notify(`Rejected: model must be "provider/modelId" or "inherit" — nothing was changed (${routePath(target)}.model).`, "error");
|
|
164
256
|
return undefined;
|
|
165
257
|
}
|
|
166
|
-
|
|
167
|
-
|
|
258
|
+
return `${parts.provider}/${parts.id}`;
|
|
259
|
+
}
|
|
260
|
+
/** Ask for a model; a lone reference is returned as-is, several references are returned as an ordered chain. */
|
|
261
|
+
async function askModel(deps, target, current) {
|
|
262
|
+
// The value in force (OB-3). For a route whose model is already a chain, seed
|
|
263
|
+
// the builder with the current references so the operator sees and edits the
|
|
264
|
+
// chain instead of rebuilding it blind (OB-17 / F2, amendment A1). Fresh and
|
|
265
|
+
// single-string editing keep the existing flow: the picker is preselected to
|
|
266
|
+
// the string value in force.
|
|
267
|
+
const seed = Array.isArray(current) ? [...current] : [];
|
|
268
|
+
if (seed.length > 0) {
|
|
269
|
+
// Already a chain: label the current chain, then let the operator append,
|
|
270
|
+
// trim, or keep it. Done with no change leaves the chain byte-identical.
|
|
271
|
+
deps.ui.notify(`Current ${target} model chain: ${seed.join(" → ")}`, "info");
|
|
272
|
+
return buildModelChain(deps, target, [...seed]);
|
|
273
|
+
}
|
|
274
|
+
const first = await pickModelEntry(deps, target, typeof current === "string" ? current : undefined);
|
|
275
|
+
if (first === undefined)
|
|
276
|
+
return undefined;
|
|
277
|
+
if (first === INHERIT)
|
|
278
|
+
return INHERIT;
|
|
279
|
+
return buildModelChain(deps, target, [first]);
|
|
168
280
|
}
|
|
169
|
-
|
|
170
|
-
|
|
281
|
+
/** Drive the ordered chain builder over the given starting chain; a single entry collapses to a bare reference. */
|
|
282
|
+
async function buildModelChain(deps, target, chain) {
|
|
283
|
+
for (;;) {
|
|
284
|
+
const atCap = chain.length >= MAX_MODEL_CHAIN;
|
|
285
|
+
if (atCap) {
|
|
286
|
+
deps.ui.notify(`A model chain is capped at ${MAX_MODEL_CHAIN} references — remove the last to make room (${routePath(target)}.model).`, "info");
|
|
287
|
+
}
|
|
288
|
+
const action = await deps.ui.select(prompts.chainAction(target), [
|
|
289
|
+
...(atCap ? [] : [prompts.chainAppend]),
|
|
290
|
+
prompts.chainRemoveLast,
|
|
291
|
+
prompts.chainDone,
|
|
292
|
+
]);
|
|
293
|
+
if (action === prompts.chainAppend) {
|
|
294
|
+
const next = await pickModelEntry(deps, target);
|
|
295
|
+
if (next === undefined)
|
|
296
|
+
continue;
|
|
297
|
+
if (next === INHERIT) {
|
|
298
|
+
deps.ui.notify(`Only "provider/modelId" references can be chain entries; "inherit" is a whole-route setting (${routePath(target)}.model).`, "error");
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
chain.push(next);
|
|
302
|
+
}
|
|
303
|
+
else if (action === prompts.chainRemoveLast) {
|
|
304
|
+
if (chain.length > 1)
|
|
305
|
+
chain.pop();
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return chain.length === 1 ? chain[0] : chain;
|
|
312
|
+
}
|
|
313
|
+
async function askThinking(deps, target, current) {
|
|
314
|
+
const options = [...THINKING_LEVELS, INHERIT];
|
|
315
|
+
let answer;
|
|
316
|
+
if (typeof deps.ui.pick === "function") {
|
|
317
|
+
const picked = await deps.ui.pick(prompts.thinking(target), options, {
|
|
318
|
+
initial: typeof current === "string" ? current : undefined,
|
|
319
|
+
});
|
|
320
|
+
answer = typeof picked === "string" ? picked : undefined;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
answer = await deps.ui.select(prompts.thinking(target), options);
|
|
324
|
+
}
|
|
171
325
|
if (answer === undefined)
|
|
172
326
|
return undefined;
|
|
173
327
|
if (answer === INHERIT || isThinkingLevel(answer))
|
|
@@ -182,6 +336,45 @@ async function pickCommand(deps, options) {
|
|
|
182
336
|
}
|
|
183
337
|
return deps.ui.select(prompts.command, [...options].sort((a, b) => a.localeCompare(b)));
|
|
184
338
|
}
|
|
339
|
+
/** Multi-select command picker over the seam's `multiple` mode; a non-rich UI falls back to repeated single selects. */
|
|
340
|
+
async function pickCommandsMulti(deps, options) {
|
|
341
|
+
if (options.length === 0) {
|
|
342
|
+
deps.ui.notify("There is no command to pick here.", "warning");
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
const sorted = [...options].sort((a, b) => a.localeCompare(b));
|
|
346
|
+
if (typeof deps.ui.pick === "function") {
|
|
347
|
+
const picked = await deps.ui.pick(prompts.command, sorted, { multiple: true });
|
|
348
|
+
if (picked === undefined)
|
|
349
|
+
return undefined;
|
|
350
|
+
return typeof picked === "string" ? [picked] : picked;
|
|
351
|
+
}
|
|
352
|
+
// Non-rich fallback: repeatedly select one candidate until the operator stops.
|
|
353
|
+
const chosen = [];
|
|
354
|
+
for (;;) {
|
|
355
|
+
const remaining = sorted.filter((option) => !chosen.includes(option));
|
|
356
|
+
if (remaining.length === 0)
|
|
357
|
+
break;
|
|
358
|
+
const picked = await deps.ui.select(prompts.command, remaining);
|
|
359
|
+
if (picked === undefined)
|
|
360
|
+
break;
|
|
361
|
+
chosen.push(picked);
|
|
362
|
+
if (!(await deps.ui.confirm(prompts.addAnother, `Picked ${chosen.join(", ")}.`)))
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
return chosen.length > 0 ? chosen : undefined;
|
|
366
|
+
}
|
|
367
|
+
/** Advisory per-command warning when a chosen reference is absent from the live registry (OB-4). */
|
|
368
|
+
function warnMissingModel(deps, target, model) {
|
|
369
|
+
if (!deps.models || deps.models.length === 0)
|
|
370
|
+
return;
|
|
371
|
+
const refs = typeof model === "string" ? [model] : model;
|
|
372
|
+
for (const ref of refs) {
|
|
373
|
+
if (ref !== INHERIT && !deps.models.includes(ref)) {
|
|
374
|
+
deps.ui.notify(`/${target}: ${ref} is not in the live model registry — dispatch will decide availability when the route runs.`, "warning");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
185
378
|
function commandChoices(deps, draft) {
|
|
186
379
|
return [...new Set([...deps.commands, ...Object.keys(draft.commands ?? {})])];
|
|
187
380
|
}
|
|
@@ -221,6 +414,7 @@ function describeRouting(file) {
|
|
|
221
414
|
file.default ? `default: ${file.default.model ?? "inherit"} / ${file.default.thinking ?? "inherit"}` : "default: inherit / inherit",
|
|
222
415
|
...Object.entries(file.commands ?? {}).map(([name, route]) => `${name}: ${route.model ?? "inherit"} / ${route.thinking ?? "inherit"}`),
|
|
223
416
|
`unavailable: ${file.onUnavailableRoute ?? "stop"}`,
|
|
417
|
+
`settle: ${file.onSettle ?? "keep"}`,
|
|
224
418
|
];
|
|
225
419
|
return routes.join(" · ");
|
|
226
420
|
}
|
|
@@ -244,6 +438,8 @@ function clean(draft) {
|
|
|
244
438
|
file.commands = commands;
|
|
245
439
|
if (draft.onUnavailableRoute)
|
|
246
440
|
file.onUnavailableRoute = draft.onUnavailableRoute;
|
|
441
|
+
if (draft.onSettle)
|
|
442
|
+
file.onSettle = draft.onSettle;
|
|
247
443
|
return file;
|
|
248
444
|
}
|
|
249
445
|
function parseFile(text, scope) {
|
|
@@ -264,3 +460,6 @@ function isThinkingLevel(value) {
|
|
|
264
460
|
function isPolicy(value) {
|
|
265
461
|
return value === "stop" || value === "inherit";
|
|
266
462
|
}
|
|
463
|
+
function isSettle(value) {
|
|
464
|
+
return value === "keep" || value === "restore";
|
|
465
|
+
}
|
|
@@ -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
|
@@ -31,10 +31,14 @@ function configLines(config, commands) {
|
|
|
31
31
|
` ${overrides.length} of ${total} commands override the default route`,
|
|
32
32
|
]),
|
|
33
33
|
` when a configured model is unavailable: ${config.onUnavailableRoute}`,
|
|
34
|
+
` after a routed command settles: ${config.onSettle}`,
|
|
34
35
|
];
|
|
35
36
|
}
|
|
36
37
|
function route(value) {
|
|
37
|
-
|
|
38
|
+
// A chain renders its references in order, with the fallback arrow the spec
|
|
39
|
+
// names (OB-14); a single reference and `inherit` keep their existing render.
|
|
40
|
+
const model = Array.isArray(value.model) ? value.model.join(" → ") : value.model;
|
|
41
|
+
return `${model} / ${value.thinking}`;
|
|
38
42
|
}
|
|
39
43
|
function problems(list) {
|
|
40
44
|
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.9.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"
|