@mjasnikovs/pi-task 0.38.32 → 0.39.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.md CHANGED
@@ -214,8 +214,8 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
214
214
  | **command timeout** | 15 min | Wall-clock ceiling on a **single** tool execution. Local models routinely run a command that never returns (a hung build, a dev server, a check with no timeout) and the run wedges until you abort by hand — pi's bash tool has an optional timeout with no default, so this is the missing one. One knob, two surfaces: in the main session the overrun call is cancelled (killing the tool's whole process tree) plus a reminder turn; in the verify/fix gate children the child is killed and re-spawned with a hint, halving the ceiling on repeat hangs. Choices: 5/10/15/30 min or **off** — off unguards both surfaces, gates included. |
215
215
  | **stuck reply retry** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
216
216
  | **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
217
- | **model: …** | all inherit | One row per group of steps, choosing which model that group's children run on — research and planning on a strong model, the rest on a cheap one. Offered from `pi.modelRegistry.getAvailable()`, stored as the canonical `provider/id` that pi's own `--model` takes, and picked from a filterable list rather than cycled. **inherit** emits no flag at all, so an all-inherit table is byte-identical to a build without this feature; that is the shipped default, because which models exist is a property of your machine and nothing here can be measured for you. Choosing a model NARROWS that group's **think:** row to the levels it declares, and re-clamps a level already stored pi erases a level a model cannot do, silently, so a cell offering one would be a lie. A stored model this machine cannot resolve is never erased (you may have set it on another machine): the flag is dropped, the step runs on pi's default, and a startup hint names the cell. Two rows deserve care: a provider registered by a host **extension** needs that extension enabled under **ext: …** or those children exit 1; and **model: implementation** is not free it is *your* session, switched for the turn and switched back, and a model switch re-bills the whole prompt as a cache miss, twice per task. |
218
- | **reasoning** | default | How much the helper sessions think before answering. Local models differ sharply here: some break without reasoning, some waste minutes with it, and some cannot do it at all. The eleven **think:** rows below set one level per group of steps the four research workers and their shared fallback, spec phases, /task-auto planning, /task-plan, the checking gates, the small extraction children, and the implementation turn itself. **default** uses the table pi-task has measured, **on** and **off** force one answer everywhere, and **custom** is whatever the think: rows say (changing any of them switches to custom). A step on **inherit** passes no flag at all, so it uses whatever thinking level pi itself is set to which is what every step did before this setting existed, and is what every cell of the measured table currently holds. The think: rows always show what a step *actually* runs at, not what the stored table says. Three tiers of model exist and pi-task tells them apart: full levels (`reasoning_effort` in the chat template, e.g. Qwen3.8), on/off only (e.g. Qwen3.6, Gemma 4), and none at all. pi silently CLAMPS a level a model does not declare — a level you set can be erased, and an `off` can be clamped back up to `medium` so a startup warning names each step, the model IT runs on, and the level that model will not honour. It never fires while every step is on inherit. |
217
+ | **profile** | default | How much the helper sessions think, in one word, for every step at once. Local models differ sharply here: some break without reasoning, some waste minutes with it, and some cannot do it at all. **default** uses the per-step table pi-task has measured, **on** and **off** force one answer everywhere and ignore that table, and **custom** is whatever the step rows saychanging any of them switches this to custom. A step on **inherit** passes no flag at all, so it uses whatever thinking level pi itself is set to, which is what every step did before this setting existed. |
218
+ | **steps: …** | all `inherit` | One row per group of steps, carrying BOTH dials: the model those children run on and the level they think at, shown as `level · model`. Enter walks a two-step picker model first, then level and **the level step offers only what that model declares, opening on the one that will actually run**. That is the whole point of the merge: pi silently CLAMPS a level a model cannot do (a level you set can be erased, and an `off` can be clamped back up to `medium`), so instead of discovering that later you watch the cursor land on the level you are really getting. Models are offered from `pi.modelRegistry.getAvailable()` and stored as the canonical `provider/id` that pi's own `--model` takes. **inherit** on the model half emits no flag, so an all-inherit table is byte-identical to a build without this feature; that is the shipped default, because which models exist is a property of your machine and nothing here can be measured for you. A stored model this machine cannot resolve is never erased (you may have set it on another machine): the flag is dropped, the step runs on pi's default, and a startup hint names the step. Two need care — a provider registered by a host **extension** needs that extension enabled under **ext: …** or those children exit 1; and **implementation** is not free, because it is *your* session moved for the turn and moved back, and a model switch re-bills the whole prompt as a cache miss, twice per task. |
219
219
  | **debug logs** | events | How much of a run is written to `.pi-tasks/*-debug.log`. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. |
220
220
  | **watch: …** | all on | One toggle per tool in the live session, deciding whether **command timeout** applies to it. The list is discovered from `pi.getAllTools()` when the menu opens — built-ins first, then each extension's tools with the owning entry-point path in the description — so nothing is typed by hand and an uninstalled tool just stops being listed. Turn one **off** only for a tool that already owns a longer bounded, cancellable contract of its own (the guard exists because pi's `bash` has an optional timeout with *no* default — that reasoning doesn't transfer to a tool that has one). Two things to know before you do: a genuine hang in an unwatched tool is caught by nothing, since **stuck reply retry** is paused for the whole time any tool runs; and an unwatched tool is still killed as collateral if a *watched* sibling in the same turn overruns, because pi runs sibling tool calls concurrently and the abort ends the whole turn. Stored as exemptions, so the default and every tool pi-task has never seen stay guarded. |
221
221
  | **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The submenu a /task-config row opens on Enter, when cycling is the wrong verb.
2
+ * The two-step picker a /task-config step row opens on Enter.
3
3
  *
4
4
  * WHY A SUBMENU AND NOT `ctx.ui.select`
5
5
  * -------------------------------------
@@ -9,8 +9,8 @@
9
9
  * in place of the list and delegates input to it. And its `done(v)` calls
10
10
  * `onChange(id, v)` with exactly the returned value while `done(undefined)`
11
11
  * writes nothing — the same one-value contract every cycling row uses, so the
12
- * panel's dispatch needs no branch and a model list of 200 costs one write
13
- * instead of 200.
12
+ * panel's dispatch needs no branch and a pair chosen out of two long lists costs
13
+ * ONE write.
14
14
  *
15
15
  * WHY NOT A BARE `SelectList`
16
16
  * ---------------------------
@@ -26,17 +26,45 @@ import { Container } from '@earendil-works/pi-tui';
26
26
  import type { SelectItem } from '@earendil-works/pi-tui';
27
27
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
28
28
  type Theme = ExtensionCommandContext['ui']['theme'];
29
+ /** What the second stage offers, once the first has been answered. */
30
+ export interface PairStage {
31
+ options: readonly SelectItem[];
32
+ /**
33
+ * The option to open on — the value that will ACTUALLY run.
34
+ *
35
+ * Supplied by the caller rather than derived here, because deciding it is
36
+ * the clamp, and the clamp belongs to the config layer that owns the model's
37
+ * declared ladder. It is what makes the auto-switch visible: pick a model
38
+ * that cannot do `medium` and this stage opens on `off`.
39
+ */
40
+ preselect: string;
41
+ }
42
+ export interface PairOptions {
43
+ first: readonly SelectItem[];
44
+ second: (firstValue: string) => PairStage;
45
+ /** Which half of the stored value stage one opens on. */
46
+ firstOf: (value: string) => string;
47
+ /** How the two answers become the one string the row stores. */
48
+ join: (first: string, second: string) => string;
49
+ }
29
50
  /**
30
- * A filterable one-of picker.
51
+ * Model, then thinking level, as one choice.
52
+ *
53
+ * Two stages rather than one flat list of every legal pair: the pair space is
54
+ * models × levels, and the common edit is "change the model, keep the level" —
55
+ * a flat list would make that re-pick both every time.
31
56
  *
32
- * `currentValue` may match NO option that is the vanished-model case, where
33
- * the stored spec is still shown by the row and must still be openable. It
34
- * simply starts at the top rather than refusing to render.
57
+ * Escape at EITHER stage cancels the whole thing. Going back one step would be
58
+ * friendlier, but a TUI select owns up, down, enter and escape and has no fifth
59
+ * key to spare, and a half-answered pair must never reach `done`.
35
60
  */
36
- export declare class OptionPicker extends Container {
37
- private readonly input;
38
- private readonly list;
39
- constructor(options: readonly SelectItem[], currentValue: string, theme: Theme, done: (value?: string) => void);
61
+ export declare class PairPicker extends Container {
62
+ private readonly options;
63
+ private readonly theme;
64
+ private readonly done;
65
+ private stage;
66
+ constructor(options: PairOptions, currentValue: string, theme: Theme, done: (value?: string) => void);
67
+ private openSecond;
40
68
  handleInput(data: string): void;
41
69
  }
42
70
  export {};
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The submenu a /task-config row opens on Enter, when cycling is the wrong verb.
2
+ * The two-step picker a /task-config step row opens on Enter.
3
3
  *
4
4
  * WHY A SUBMENU AND NOT `ctx.ui.select`
5
5
  * -------------------------------------
@@ -9,8 +9,8 @@
9
9
  * in place of the list and delegates input to it. And its `done(v)` calls
10
10
  * `onChange(id, v)` with exactly the returned value while `done(undefined)`
11
11
  * writes nothing — the same one-value contract every cycling row uses, so the
12
- * panel's dispatch needs no branch and a model list of 200 costs one write
13
- * instead of 200.
12
+ * panel's dispatch needs no branch and a pair chosen out of two long lists costs
13
+ * ONE write.
14
14
  *
15
15
  * WHY NOT A BARE `SelectList`
16
16
  * ---------------------------
@@ -35,23 +35,29 @@ function selectTheme(theme) {
35
35
  };
36
36
  }
37
37
  /**
38
- * A filterable one-of picker.
38
+ * A filterable list with a text filter above it.
39
39
  *
40
- * `currentValue` may match NO option that is the vanished-model case, where
41
- * the stored spec is still shown by the row and must still be openable. It
42
- * simply starts at the top rather than refusing to render.
40
+ * Split out because both stages are one of these and the routing is the part
41
+ * that is easy to get wrong: `SelectList` owns four keys and drops the rest, so
42
+ * everything else has to be handed to the `Input` and the filter re-applied.
43
43
  */
44
- export class OptionPicker extends Container {
44
+ class FilterList extends Container {
45
+ onPick;
46
+ onCancel;
45
47
  input = new Input();
46
48
  list;
47
- constructor(options, currentValue, theme, done) {
49
+ constructor(options, preselect, theme, onPick, onCancel) {
48
50
  super();
51
+ this.onPick = onPick;
52
+ this.onCancel = onCancel;
49
53
  this.list = new SelectList([...options], PICKER_VISIBLE, selectTheme(theme));
50
- const at = options.findIndex(o => o.value === currentValue);
54
+ // A `preselect` matching no option is normal, not an error: a row can
55
+ // hold a spec the catalog no longer offers, and it must still open.
56
+ const at = options.findIndex(o => o.value === preselect);
51
57
  if (at !== -1)
52
58
  this.list.setSelectedIndex(at);
53
- this.list.onSelect = item => done(item.value);
54
- this.list.onCancel = () => done(undefined);
59
+ this.list.onSelect = item => this.onPick(item.value);
60
+ this.list.onCancel = () => this.onCancel();
55
61
  this.addChild(this.input);
56
62
  this.addChild(this.list);
57
63
  }
@@ -71,3 +77,37 @@ export class OptionPicker extends Container {
71
77
  this.list.setFilter(this.input.getValue());
72
78
  }
73
79
  }
80
+ /**
81
+ * Model, then thinking level, as one choice.
82
+ *
83
+ * Two stages rather than one flat list of every legal pair: the pair space is
84
+ * models × levels, and the common edit is "change the model, keep the level" —
85
+ * a flat list would make that re-pick both every time.
86
+ *
87
+ * Escape at EITHER stage cancels the whole thing. Going back one step would be
88
+ * friendlier, but a TUI select owns up, down, enter and escape and has no fifth
89
+ * key to spare, and a half-answered pair must never reach `done`.
90
+ */
91
+ export class PairPicker extends Container {
92
+ options;
93
+ theme;
94
+ done;
95
+ stage;
96
+ constructor(options, currentValue, theme, done) {
97
+ super();
98
+ this.options = options;
99
+ this.theme = theme;
100
+ this.done = done;
101
+ this.stage = new FilterList(options.first, options.firstOf(currentValue), theme, first => this.openSecond(first), () => done(undefined));
102
+ this.addChild(this.stage);
103
+ }
104
+ openSecond(first) {
105
+ const { options: second, preselect } = this.options.second(first);
106
+ this.stage = new FilterList(second, preselect, this.theme, level => this.done(this.options.join(first, level)), () => this.done(undefined));
107
+ this.clear();
108
+ this.addChild(this.stage);
109
+ }
110
+ handleInput(data) {
111
+ this.stage.handleInput(data);
112
+ }
113
+ }
@@ -104,13 +104,16 @@ export declare function effectiveReasoning(cfg: PiTaskConfig): Record<ChildGroup
104
104
  * version before this feature existed.
105
105
  */
106
106
  export declare function thinkingArgs(setting: GroupSetting): string[];
107
- /** One honest sentence per group, for the /task-config rows. */
108
- export declare const REASONING_GROUP_HELP: Readonly<Record<ChildGroup, string>>;
109
107
  /**
110
- * One honest sentence per model row.
108
+ * One honest sentence per step, for the merged /task-config rows.
111
109
  *
112
- * They are NOT the reasoning help reworded. A model cell answers a different
113
- * question which machine does this work and one of them costs money every
114
- * turn rather than once per change, which is a thing the row has to say.
110
+ * ONE table, not the model help and the reasoning help concatenated. The panel
111
+ * sizes every frame from its tallest description (`settingsBodyHeight`), so
112
+ * gluing two paragraphs together for `implementation` would permanently add
113
+ * about six rows to the whole menu — for a row most users never open.
114
+ *
115
+ * What survives the merge is the part that is not guessable: which children the
116
+ * step actually spawns, what was MEASURED about its thinking level, and the one
117
+ * cost that is paid per turn rather than per change.
115
118
  */
116
- export declare const MODEL_GROUP_HELP: Readonly<Record<ChildGroup, string>>;
119
+ export declare const STEP_GROUP_HELP: Readonly<Record<ChildGroup, string>>;
@@ -105,12 +105,23 @@ export function effectiveReasoning(cfg) {
105
105
  export function thinkingArgs(setting) {
106
106
  return setting === 'inherit' ? [] : ['--thinking', setting];
107
107
  }
108
- /** One honest sentence per group, for the /task-config rows. */
109
- export const REASONING_GROUP_HELP = {
110
- research: 'The pi-worker subagent tool, and the fallback for any research worker below '
111
- + 'whose own level is unset. Read-only exploration loops.',
108
+ /**
109
+ * One honest sentence per step, for the merged /task-config rows.
110
+ *
111
+ * ONE table, not the model help and the reasoning help concatenated. The panel
112
+ * sizes every frame from its tallest description (`settingsBodyHeight`), so
113
+ * gluing two paragraphs together for `implementation` would permanently add
114
+ * about six rows to the whole menu — for a row most users never open.
115
+ *
116
+ * What survives the merge is the part that is not guessable: which children the
117
+ * step actually spawns, what was MEASURED about its thinking level, and the one
118
+ * cost that is paid per turn rather than per change.
119
+ */
120
+ export const STEP_GROUP_HELP = {
121
+ research: 'The pi-worker subagent tool, and the fallback for any research worker below. '
122
+ + 'Long read-only loops, where a cheap fast model pays off.',
112
123
  'research:files': 'Research worker 1 of 4: maps which files the task will touch. Read-heavy. '
113
- + 'Measured: the two arms tie, so it runs without thinking.',
124
+ + 'Measured: the two thinking arms tie, so it runs without.',
114
125
  'research:apis': 'Research worker 2 of 4: the symbols and signatures the task must call. '
115
126
  + 'Read-heavy, docs- and search-capable.',
116
127
  'research:context': 'Research worker 3 of 4: how the project is put together. One of the two that '
@@ -125,31 +136,7 @@ export const REASONING_GROUP_HELP = {
125
136
  gate: 'The checks that run after code is written: verify, enforce, lint-fix, autofix.',
126
137
  extraction: 'The small no-tools children that pull one answer out of a fetched page or '
127
138
  + 'a docs chunk.',
128
- implementation: 'The main session turn that actually writes the code. Changing this briefly '
129
- + "changes pi's own thinking level, and puts it back afterwards."
130
- };
131
- /**
132
- * One honest sentence per model row.
133
- *
134
- * They are NOT the reasoning help reworded. A model cell answers a different
135
- * question — which machine does this work — and one of them costs money every
136
- * turn rather than once per change, which is a thing the row has to say.
137
- */
138
- export const MODEL_GROUP_HELP = {
139
- research: 'The pi-worker subagent tool, and the fallback for any research worker below. '
140
- + 'Long read-only loops: a cheap fast model pays off here.',
141
- 'research:files': 'Research worker 1 of 4: maps which files the task will touch.',
142
- 'research:apis': 'Research worker 2 of 4: the symbols and signatures the task must call.',
143
- 'research:context': 'Research worker 3 of 4: how the project is put together.',
144
- 'research:tooling': 'Research worker 4 of 4: the commands that build, test and run it.',
145
- phase: 'Refining your request, generating and answering the clarifying questions, '
146
- + 'writing the spec, and critiquing it.',
147
- planning: "/task-auto's planners: splitting a design document into tasks.",
148
- plan: "/task-plan's interactive question-and-answer children.",
149
- gate: 'The checks that run after code is written: verify, enforce, lint-fix, autofix.',
150
- extraction: 'The small no-tools children that pull one answer out of a page or a docs chunk.',
151
- implementation: 'The main session turn that writes the code — YOUR session, switched for the turn and '
152
- + 'switched back. Unlike every row above, this one is not free: a model switch re-bills '
153
- + 'the whole prompt as a cache miss, twice per task. Leave it on inherit unless you '
154
- + 'want a different model than the one you are reading this in.'
139
+ implementation: 'The main session turn that writes the code YOUR session, moved for the turn and '
140
+ + 'moved back. Unlike every step above, the model half here is not free: a switch '
141
+ + 're-bills the whole prompt as a cache miss, twice per task.'
155
142
  };
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
2
  import { SettingsList } from '@earendil-works/pi-tui';
3
- import type { Component, SelectItem } from '@earendil-works/pi-tui';
3
+ import type { Component } from '@earendil-works/pi-tui';
4
4
  import { type ReasoningModelFacts } from '../shared/reasoning-capability.js';
5
+ import { type PairOptions } from './option-picker.js';
5
6
  import { type PiTaskConfig } from './config.js';
6
7
  import { type InstalledExtension } from './extension-list.js';
7
8
  import { type GuardableTool } from './tool-list.js';
@@ -55,7 +56,7 @@ declare class BorderedBox implements Component {
55
56
  export interface ConfigItem {
56
57
  /**
57
58
  * The row's id. A `keyof PiTaskConfig` for a fixed setting; a prefixed
58
- * string (`reason:`, `tool:`, `ext:`) for a DISCOVERED one.
59
+ * string (`step:`, `tool:`, `ext:`) for a DISCOVERED one.
59
60
  *
60
61
  * It is `string`, not `keyof PiTaskConfig`, and that is what lets the three
61
62
  * dynamic families BE rows instead of bypassing them — so the round-trip
@@ -84,7 +85,7 @@ export interface ConfigItem {
84
85
  * `values` stays the STATIC complete vocabulary, because that is what the
85
86
  * round-trip property in config-items.test.ts quantifies over.
86
87
  */
87
- submenu?: (cfg: PiTaskConfig) => SelectItem[];
88
+ picker?: (cfg: PiTaskConfig) => PairOptions;
88
89
  /** What the panel shows for the current value. */
89
90
  format: (cfg: PiTaskConfig) => string;
90
91
  /** Write the chosen label back. A value it does not recognise is ignored. */
@@ -104,7 +105,7 @@ export interface ConfigItem {
104
105
  * exemptions from it) end up separated by rows that have nothing to do with
105
106
  * them. The headers are inert rows: no `values`, so Enter does nothing on them.
106
107
  */
107
- export type Section = 'session' | 'checks' | 'research' | 'models' | 'reasoning' | 'timeouts' | 'unattended' | 'logging' | 'extensions';
108
+ export type Section = 'session' | 'checks' | 'research' | 'profile' | 'reasoning' | 'timeouts' | 'unattended' | 'logging' | 'extensions';
108
109
  /** Section order, and the label each header renders. */
109
110
  export declare const SECTIONS: ReadonlyArray<{
110
111
  key: Section;
@@ -138,7 +139,7 @@ export declare function toolItems(tools: readonly GuardableTool[]): ConfigItem[]
138
139
  /** Apply a per-tool watchdog toggle to the exemption list (idempotent both ways). */
139
140
  export declare function applyToolToggle(exempt: readonly string[], toolName: string, watched: boolean): string[];
140
141
  /**
141
- * The label for one `think:` row.
142
+ * The label for one step row.
142
143
  *
143
144
  * A group whose name carries a colon is a CHILD of the group before the colon —
144
145
  * `research:files` is one of the four workers `research` fans out to. Rendered
@@ -147,14 +148,18 @@ export declare function applyToolToggle(exempt: readonly string[], toolName: str
147
148
  * parent is also a row.
148
149
  *
149
150
  * So a child is drawn as a tree branch under its parent and loses the repeated
150
- * `think: research:` prefix, the same text at the head of four consecutive
151
- * lines. `└─` on the last child, `├─` on the rest, decided from the group's
152
- * position in {@link CHILD_GROUPS} rather than a hand-kept list — adding a
153
- * fifth worker moves the corner on its own.
151
+ * `research:` prefix, the same text at the head of four consecutive lines. `└─`
152
+ * on the last child, `├─` on the rest, decided from the group's position in
153
+ * {@link CHILD_GROUPS} rather than a hand-kept list — adding a fifth worker
154
+ * moves the corner on its own.
155
+ *
156
+ * A parentless group is its own bare name. It carried a `think: ` prefix while
157
+ * there were two families to tell apart; with one row per step there is nothing
158
+ * to disambiguate, and the prefix was the widest thing in the column.
154
159
  *
155
160
  * Leading spaces survive: SettingsList pads the label right, never trims it.
156
161
  */
157
- export declare function reasoningRowLabel(group: ChildGroup): string;
162
+ export declare function stepRowLabel(group: ChildGroup): string;
158
163
  /**
159
164
  * What this machine can offer a model row, and what each offer can DO.
160
165
  *
@@ -173,26 +178,6 @@ export interface ModelCatalog {
173
178
  }
174
179
  /** No registry reachable. Every row still renders; nothing narrows. */
175
180
  export declare const EMPTY_CATALOG: ModelCatalog;
176
- export declare function modelItems(catalog: ModelCatalog): ConfigItem[];
177
- /**
178
- * Write a group's model, and RE-CLAMP its thinking cell in the same write.
179
- *
180
- * Narrowing the picker does nothing about a level already stored from before the
181
- * model was chosen: on its own it would freeze a lie into a cell it has just
182
- * made unconfigurable. `syncRows` re-renders every row, so the user sees the
183
- * level move.
184
- *
185
- * The clamp reads `resolveReasoning`, NOT `cfg.reasoningLevels[group]`. In mode
186
- * `on` or `off` the stored table is ignored entirely, so a user in `on` who
187
- * picks a non-reasoning model has a stored cell that says nothing and an
188
- * effective `medium` the model will erase — and comparing the stored cell would
189
- * see no clamp and stay silent about a real lie.
190
- *
191
- * Guarded on the clamp actually MOVING something, because `applyReasoningLevel`
192
- * flips the whole table to `custom`, and picking a fully-capable model must not
193
- * do that as a side effect.
194
- */
195
- export declare function applyGroupModel(cfg: PiTaskConfig, group: ChildGroup, chosen: string, catalog: ModelCatalog): void;
196
181
  /**
197
182
  * The levels a row may offer, given the model that row's group will run on.
198
183
  *
@@ -202,7 +187,56 @@ export declare function applyGroupModel(cfg: PiTaskConfig, group: ChildGroup, ch
202
187
  * offer them. A model declaring `xhigh` must not smuggle it in here.
203
188
  */
204
189
  export declare function offeredLevels(facts: ReasoningModelFacts | undefined): GroupSetting[];
205
- export declare function reasoningItems(catalog?: ModelCatalog): ConfigItem[];
190
+ /**
191
+ * `level · provider/id`, the one string a step row shows and accepts.
192
+ *
193
+ * THE LEVEL COMES FIRST, and that is a display decision with teeth.
194
+ * `SettingsList` truncates a value from the RIGHT, so whatever is last is what
195
+ * silently disappears — and a real local model id (`local/Qwen3.8-27B-UD-Q4_K_XL
196
+ * .gguf`) is wide enough to consume the whole column on its own. Level-first
197
+ * means the half that falls off is the one still identifiable from its head, and
198
+ * the levels line up as a column you can read down.
199
+ */
200
+ export declare function formatStepValue(spec: string, level: GroupSetting): string;
201
+ /**
202
+ * The two halves back out, or `undefined` for anything not of that shape.
203
+ *
204
+ * Split on the FIRST separator, because the level leads and is a closed set,
205
+ * while a `provider/id` could conceivably contain one.
206
+ */
207
+ export declare function parseStepValue(value: string): {
208
+ spec: string;
209
+ level: GroupSetting;
210
+ } | undefined;
211
+ /**
212
+ * One row per step, carrying BOTH dials.
213
+ *
214
+ * They were two parallel blocks of eleven, and the coupling between them was
215
+ * invisible: choosing a model re-clamps that step's thinking level, but the row
216
+ * that moved was eleven rows away from the row you touched. One row shows the
217
+ * pair, and the two-step picker shows the clamp happening.
218
+ *
219
+ * `values` is the LEGAL cross product — every model against only the levels that
220
+ * model declares. Nothing renders it: the picker offers two short lists, and the
221
+ * round-trip property in config-items.test.ts is its only reader. Building it
222
+ * from `offeredLevels` rather than the full ladder is what makes the property
223
+ * true, because a pair the model cannot honour would be clamped by `apply` and
224
+ * would not round-trip.
225
+ */
226
+ export declare function stepItems(catalog?: ModelCatalog): ConfigItem[];
227
+ /**
228
+ * Write both halves of a step row, atomically.
229
+ *
230
+ * Atomically matters for the round-trip property, which starts from a FRESH
231
+ * config every iteration: a value that wrote only one half would leave the other
232
+ * at its default and render as something else.
233
+ *
234
+ * The level is re-clamped even though the picker only ever offers legal pairs.
235
+ * The picker is not the only door — `values` is built when the panel opens, and
236
+ * a registry that moved underneath it would otherwise let an unhonourable level
237
+ * through.
238
+ */
239
+ export declare function applyStepValue(cfg: PiTaskConfig, group: ChildGroup, chosen: string, catalog: ModelCatalog): void;
206
240
  /**
207
241
  * Apply one group row's new value.
208
242
  *
@@ -247,13 +281,13 @@ export type PanelItem = {
247
281
  * {@link createSettingsPanel} is where a theme exists, so it is where these
248
282
  * options become a component.
249
283
  */
250
- submenuOptions?: () => SelectItem[];
284
+ pickerOptions?: () => PairOptions;
251
285
  /**
252
286
  * What the headless one-line rendering calls this row, when `label` reads
253
287
  * only in the panel. A tree branch means nothing on a line of `|`-joined
254
- * rows: `├─ files` there names no parent, where `think: research:files`
255
- * does. Set by the reasoning rows; every other row leaves it off and its
256
- * `label` is used.
288
+ * rows: `├─ files` there names no parent, where `step: research:files`
289
+ * does. Set by the step rows; every other row leaves it off and its `label`
290
+ * is used.
257
291
  */
258
292
  headlessLabel?: string;
259
293
  };
@@ -267,10 +301,10 @@ export declare function createSettingsPanel(items: PanelItem[], theme: Theme,
267
301
  * Called with the row's id, its new value, and the LIST ITSELF.
268
302
  *
269
303
  * The list is handed back because some rows change what OTHER rows display:
270
- * flipping `reasoning` to off means every `think:` row now runs at off, and
271
- * a row's `currentValue` is a snapshot taken when the panel was built.
272
- * Without a way to write the others back, the menu would show
273
- * `reasoning off` beside rows still claiming `inherit`.
304
+ * flipping `profile` to off means every `step:` row now runs at off, and a
305
+ * row's `currentValue` is a snapshot taken when the panel was built. Without
306
+ * a way to write the others back, the menu would show `profile off` beside
307
+ * rows still claiming `inherit`.
274
308
  */
275
309
  onChange: (id: string, newValue: string, list: SettingsList) => void, onCancel: () => void): BorderedBox;
276
310
  /**
@@ -294,7 +328,7 @@ export declare function panelItems(cfg: PiTaskConfig, installed: InstalledExtens
294
328
  *
295
329
  * A row's `currentValue` in the live list is a snapshot taken when the panel was
296
330
  * built, and rows describe each other: cycling `reasoning` to `off` changes what
297
- * every `think:` row runs at, and cycling one group row flips the mode, which
331
+ * every `step:` row runs at, and setting one step row flips the profile, which
298
332
  * changes all the others.
299
333
  *
300
334
  * This runs after ANY change, over EVERY row. Re-reading a `format` costs
@@ -1,14 +1,14 @@
1
1
  import { getKeybindings, SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
2
  import { clampToModel, supportedThinkingLevels } from '../shared/reasoning-capability.js';
3
- import { isModelSpec, MODEL_INHERIT, splitSpec } from './group-models.js';
4
- import { OptionPicker } from './option-picker.js';
3
+ import { MODEL_INHERIT, splitSpec } from './group-models.js';
4
+ import { PairPicker } from './option-picker.js';
5
5
  import { registerBridgeCommand } from '../remote/bridge.js';
6
6
  import { readPkgVersion } from '../shared/pkg-version.js';
7
7
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
8
8
  import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
9
9
  import { listInstalledExtensions } from './extension-list.js';
10
10
  import { listGuardableTools } from './tool-list.js';
11
- import { CHILD_GROUPS, REASONING_MODES, sanitizeReasoningMode, REASONING_GROUP_HELP, MODEL_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
11
+ import { CHILD_GROUPS, REASONING_MODES, sanitizeReasoningMode, STEP_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
12
12
  // Version in the title so a bug report or screenshot says which build it came
13
13
  // from without anyone having to go look it up.
14
14
  const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
@@ -76,12 +76,20 @@ export const SECTIONS = [
76
76
  { key: 'session', title: 'session' },
77
77
  { key: 'checks', title: 'after each task' },
78
78
  { key: 'research', title: 'research' },
79
- // Immediately before `reasoning`, and a BLOCK of its own rather than a
80
- // second column on the think: rows. Interleaving would double one block to
81
- // 22 rows and print the `├─ files` tree branches twice, meaning two
82
- // different things a line apart.
83
- { key: 'models', title: 'models' },
84
- { key: 'reasoning', title: 'reasoning' },
79
+ // The global override gets its own heading, so it cannot be mistaken for a
80
+ // twelfth step. It is one row, and `renderRows` drops an empty section, so
81
+ // this costs a header and a blank line and nothing else.
82
+ //
83
+ // Both titles are SHORT on purpose. `SettingsList` sizes its label column
84
+ // from the widest item it holds — headers included — so a long heading is
85
+ // taken straight out of every row's value column, and the first thing to
86
+ // fall off the right is the thinking level.
87
+ { key: 'profile', title: 'profile' },
88
+ // ONE block for both dials. They shipped as two parallel blocks of eleven,
89
+ // which put a step's two settings eleven rows apart and hid the coupling
90
+ // between them: choosing a model re-clamps that step's thinking level, and
91
+ // nobody could see it happen.
92
+ { key: 'reasoning', title: 'steps' },
85
93
  { key: 'unattended', title: 'unattended' },
86
94
  { key: 'logging', title: 'logging' },
87
95
  { key: 'extensions', title: 'child extensions' },
@@ -229,15 +237,17 @@ export const ITEMS = [
229
237
  + 'For throwaway projects you are not watching'),
230
238
  {
231
239
  id: 'reasoningMode',
232
- section: 'reasoning',
233
- label: 'reasoning',
234
- description: 'How much the helper sessions think before answering. "default" uses the '
235
- + 'per-step table pi-task has measured, "on" and "off" force one answer '
236
- + 'everywhere, and "custom" is whatever you set in the "think:" rows below. '
237
- + 'Those rows always show what each step actually runs at, and changing one '
238
- + 'switches this to custom. A step left on "inherit" uses whatever thinking '
239
- + 'level pi itself is set to, which is what every step did before this setting '
240
- + 'existed',
240
+ // Its OWN section, above the per-step block it governs. Sitting inside
241
+ // that block it read as a twelfth step called `reasoning` — a global
242
+ // override disguised as one more per-step row.
243
+ section: 'profile',
244
+ label: 'profile',
245
+ description: 'How much every step below thinks, in one word. "default" uses the per-step '
246
+ + 'table pi-task has measured, "on" and "off" force one answer everywhere and '
247
+ + 'IGNORE the rows below, and "custom" is whatever those rows say. They always '
248
+ + 'show what each step actually runs at, so changing one switches this to '
249
+ + 'custom. A step left on "inherit" uses whatever thinking level pi itself is '
250
+ + 'set to, which is what every step did before this setting existed',
241
251
  values: [...REASONING_MODES],
242
252
  format: cfg => String(cfg.reasoningMode),
243
253
  apply: (cfg, chosen) => {
@@ -340,9 +350,9 @@ export function applyToolToggle(exempt, toolName, watched) {
340
350
  * though the custom table underneath is untouched, which is the honest
341
351
  * answer to "what will my next child do".
342
352
  */
343
- const REASON_ID_PREFIX = 'reason:';
353
+ const STEP_ID_PREFIX = 'step:';
344
354
  /**
345
- * The label for one `think:` row.
355
+ * The label for one step row.
346
356
  *
347
357
  * A group whose name carries a colon is a CHILD of the group before the colon —
348
358
  * `research:files` is one of the four workers `research` fans out to. Rendered
@@ -351,88 +361,27 @@ const REASON_ID_PREFIX = 'reason:';
351
361
  * parent is also a row.
352
362
  *
353
363
  * So a child is drawn as a tree branch under its parent and loses the repeated
354
- * `think: research:` prefix, the same text at the head of four consecutive
355
- * lines. `└─` on the last child, `├─` on the rest, decided from the group's
356
- * position in {@link CHILD_GROUPS} rather than a hand-kept list — adding a
357
- * fifth worker moves the corner on its own.
364
+ * `research:` prefix, the same text at the head of four consecutive lines. `└─`
365
+ * on the last child, `├─` on the rest, decided from the group's position in
366
+ * {@link CHILD_GROUPS} rather than a hand-kept list — adding a fifth worker
367
+ * moves the corner on its own.
368
+ *
369
+ * A parentless group is its own bare name. It carried a `think: ` prefix while
370
+ * there were two families to tell apart; with one row per step there is nothing
371
+ * to disambiguate, and the prefix was the widest thing in the column.
358
372
  *
359
373
  * Leading spaces survive: SettingsList pads the label right, never trims it.
360
374
  */
361
- export function reasoningRowLabel(group) {
375
+ export function stepRowLabel(group) {
362
376
  const colon = group.indexOf(':');
363
377
  if (colon < 0)
364
- return `think: ${group}`;
378
+ return group;
365
379
  const parent = group.slice(0, colon);
366
380
  const nextIsSibling = CHILD_GROUPS[CHILD_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
367
381
  return ` ${nextIsSibling ? '├─' : '└─'} ${group.slice(colon + 1)}`;
368
382
  }
369
- const MODEL_ID_PREFIX = 'model:';
370
383
  /** No registry reachable. Every row still renders; nothing narrows. */
371
384
  export const EMPTY_CATALOG = { specs: [], facts: () => undefined };
372
- export function modelItems(catalog) {
373
- return CHILD_GROUPS.map(group => ({
374
- id: MODEL_ID_PREFIX + group,
375
- section: 'models',
376
- label: reasoningRowLabel(group).replace('think: ', 'model: '),
377
- headlessLabel: `model: ${group}`,
378
- description: MODEL_GROUP_HELP[group],
379
- // The COMPLETE accepted vocabulary, which is what the round-trip
380
- // property quantifies over. With nothing discovered it is `['inherit']`:
381
- // length 1, still selectable, still not a header. No degenerate case.
382
- values: [MODEL_INHERIT, ...catalog.specs],
383
- submenu: () => [
384
- { value: MODEL_INHERIT, label: MODEL_INHERIT, description: "pi's own default" },
385
- ...catalog.specs.map(spec => ({
386
- value: spec,
387
- label: spec,
388
- ...(catalog.note?.(spec) === undefined ? {} : { description: catalog.note(spec) })
389
- }))
390
- ],
391
- // VERBATIM, even when absent from `values` — the vanished-model case,
392
- // where the row must still say what the config holds so the hint that
393
- // names it has something to point at.
394
- format: cfg => cfg.groupModels[group],
395
- apply: (cfg, chosen) => applyGroupModel(cfg, group, chosen, catalog)
396
- }));
397
- }
398
- /**
399
- * Write a group's model, and RE-CLAMP its thinking cell in the same write.
400
- *
401
- * Narrowing the picker does nothing about a level already stored from before the
402
- * model was chosen: on its own it would freeze a lie into a cell it has just
403
- * made unconfigurable. `syncRows` re-renders every row, so the user sees the
404
- * level move.
405
- *
406
- * The clamp reads `resolveReasoning`, NOT `cfg.reasoningLevels[group]`. In mode
407
- * `on` or `off` the stored table is ignored entirely, so a user in `on` who
408
- * picks a non-reasoning model has a stored cell that says nothing and an
409
- * effective `medium` the model will erase — and comparing the stored cell would
410
- * see no clamp and stay silent about a real lie.
411
- *
412
- * Guarded on the clamp actually MOVING something, because `applyReasoningLevel`
413
- * flips the whole table to `custom`, and picking a fully-capable model must not
414
- * do that as a side effect.
415
- */
416
- export function applyGroupModel(cfg, group, chosen, catalog) {
417
- // MEMBERSHIP, not just shape. A stored spec naming a vanished model must
418
- // survive — that is the sanitizer's job, and `format` still renders it — but
419
- // it may only ever ARRIVE here from the picker, which offers exactly these.
420
- // Without this, the panel could write a spec this machine cannot resolve.
421
- if (!isModelSpec(chosen))
422
- return;
423
- if (chosen !== MODEL_INHERIT && !catalog.specs.includes(chosen))
424
- return;
425
- cfg.groupModels = { ...cfg.groupModels, [group]: chosen };
426
- const facts = catalog.facts(chosen);
427
- if (facts === undefined)
428
- return;
429
- const wanted = resolveReasoning(group, cfg);
430
- if (wanted === 'inherit')
431
- return;
432
- const clamped = clampToModel(facts, wanted);
433
- if (clamped !== wanted)
434
- applyReasoningLevel(cfg, group, clamped);
435
- }
436
385
  /**
437
386
  * The levels a row may offer, given the model that row's group will run on.
438
387
  *
@@ -447,30 +396,171 @@ export function offeredLevels(facts) {
447
396
  const supported = supportedThinkingLevels(facts);
448
397
  return REASONING_SETTINGS.filter(s => s === 'inherit' || supported.includes(s));
449
398
  }
450
- export function reasoningItems(catalog = EMPTY_CATALOG) {
399
+ /** The separator between a step row's two halves. */
400
+ const PAIR_SEP = ' \u00b7 ';
401
+ /**
402
+ * `level · provider/id`, the one string a step row shows and accepts.
403
+ *
404
+ * THE LEVEL COMES FIRST, and that is a display decision with teeth.
405
+ * `SettingsList` truncates a value from the RIGHT, so whatever is last is what
406
+ * silently disappears — and a real local model id (`local/Qwen3.8-27B-UD-Q4_K_XL
407
+ * .gguf`) is wide enough to consume the whole column on its own. Level-first
408
+ * means the half that falls off is the one still identifiable from its head, and
409
+ * the levels line up as a column you can read down.
410
+ */
411
+ export function formatStepValue(spec, level) {
412
+ return `${level}${PAIR_SEP}${spec}`;
413
+ }
414
+ /**
415
+ * The two halves back out, or `undefined` for anything not of that shape.
416
+ *
417
+ * Split on the FIRST separator, because the level leads and is a closed set,
418
+ * while a `provider/id` could conceivably contain one.
419
+ */
420
+ export function parseStepValue(value) {
421
+ const at = value.indexOf(PAIR_SEP);
422
+ if (at <= 0)
423
+ return undefined;
424
+ const level = value.slice(0, at);
425
+ const spec = value.slice(at + PAIR_SEP.length);
426
+ if (spec === '' || !REASONING_SETTINGS.includes(level))
427
+ return undefined;
428
+ return { spec, level: level };
429
+ }
430
+ /**
431
+ * One row per step, carrying BOTH dials.
432
+ *
433
+ * They were two parallel blocks of eleven, and the coupling between them was
434
+ * invisible: choosing a model re-clamps that step's thinking level, but the row
435
+ * that moved was eleven rows away from the row you touched. One row shows the
436
+ * pair, and the two-step picker shows the clamp happening.
437
+ *
438
+ * `values` is the LEGAL cross product — every model against only the levels that
439
+ * model declares. Nothing renders it: the picker offers two short lists, and the
440
+ * round-trip property in config-items.test.ts is its only reader. Building it
441
+ * from `offeredLevels` rather than the full ladder is what makes the property
442
+ * true, because a pair the model cannot honour would be clamped by `apply` and
443
+ * would not round-trip.
444
+ */
445
+ export function stepItems(catalog = EMPTY_CATALOG) {
446
+ const specs = [MODEL_INHERIT, ...catalog.specs];
451
447
  return CHILD_GROUPS.map(group => ({
452
- id: REASON_ID_PREFIX + group,
448
+ id: STEP_ID_PREFIX + group,
453
449
  section: 'reasoning',
454
- label: reasoningRowLabel(group),
455
- headlessLabel: `think: ${group}`,
456
- description: REASONING_GROUP_HELP[group],
457
- values: [...REASONING_SETTINGS],
458
- // A submenu, not a cycle, because what this row may offer depends on the
459
- // model row above it which the user can change while the panel is
460
- // open. `values` is static and computed when the rows are built, and
461
- // `syncRows` can only re-ask `format`; a factory runs at Enter-time.
462
- submenu: (cfg) => offeredLevels(catalog.facts(cfg.groupModels[group])).map(level => ({
463
- value: level,
464
- label: level
465
- })),
466
- // The EFFECTIVE level, not cfg.reasoningLevels[group]: in default/on/off
467
- // the stored table is not what runs, and a row that shows a value the
468
- // run does not use is worse than no row. As a FUNCTION rather than a
469
- // snapshot, so `syncRows` can re-ask after any change.
470
- format: cfg => resolveReasoning(group, cfg),
471
- apply: (cfg, chosen) => applyReasoningLevel(cfg, group, chosen)
450
+ label: stepRowLabel(group),
451
+ headlessLabel: `step: ${group}`,
452
+ description: STEP_GROUP_HELP[group],
453
+ values: specs.flatMap(spec => offeredLevels(catalog.facts(spec)).map(level => formatStepValue(spec, level))),
454
+ // Built at ENTER-time from the LIVE draft, not when the rows were made:
455
+ // stage two narrows to the model chosen in stage one, and the user can
456
+ // have changed another row since the panel opened.
457
+ picker: cfg => stepPicker(group, cfg, catalog),
458
+ // The model half VERBATIM even when the catalog no longer offers it — the
459
+ // vanished-model case, where the row is the only place the user can see
460
+ // what their config actually holds. The thinking half is the EFFECTIVE
461
+ // level, not the stored cell: in mode default/on/off the stored table is
462
+ // not what runs, and a row showing a value the run does not use is worse
463
+ // than no row.
464
+ format: cfg => formatStepValue(cfg.groupModels[group], resolveReasoning(group, cfg)),
465
+ apply: (cfg, chosen) => applyStepValue(cfg, group, chosen, catalog)
472
466
  }));
473
467
  }
468
+ /**
469
+ * The two lists behind one step row.
470
+ *
471
+ * Stage two is where the coupling becomes visible. It offers only the levels the
472
+ * chosen model declares, and it OPENS on the level that will actually run — the
473
+ * current one when that model can honour it, otherwise the clamp, with the
474
+ * reason written beside it. Picking a model that cannot think is therefore not a
475
+ * silent downgrade discovered later; it is the option the cursor is already on.
476
+ */
477
+ function stepPicker(group, cfg, catalog) {
478
+ const held = cfg.groupModels[group];
479
+ // The row's CURRENT model leads stage one when the catalog cannot offer it.
480
+ // Without this, opening the row to nudge only the level would silently
481
+ // rewrite the model to `inherit`: `FilterList` falls back to index 0 when
482
+ // the preselect matches nothing, and the two dials are one row now, so there
483
+ // is no way to touch the level without confirming a model. That would erase
484
+ // a spec set on the user's other machine — the one thing the loader, the
485
+ // format function and the startup hint all go out of their way to preserve.
486
+ const missing = held !== MODEL_INHERIT && !catalog.specs.includes(held);
487
+ return {
488
+ first: [
489
+ ...(missing ?
490
+ [{ value: held, label: held, description: 'not available here — kept as-is' }]
491
+ : []),
492
+ { value: MODEL_INHERIT, label: MODEL_INHERIT, description: "pi's own default" },
493
+ ...catalog.specs.map(spec => ({
494
+ value: spec,
495
+ label: spec,
496
+ ...(catalog.note?.(spec) === undefined ? {} : { description: catalog.note(spec) })
497
+ }))
498
+ ],
499
+ second: spec => {
500
+ const facts = catalog.facts(spec);
501
+ const offered = offeredLevels(facts);
502
+ const wanted = resolveReasoning(group, cfg);
503
+ const clamped = facts === undefined || wanted === 'inherit' ?
504
+ wanted
505
+ : clampToModel(facts, wanted);
506
+ // Back inside the menu's own vocabulary. `clampToModel` walks UP
507
+ // first and knows the whole ladder, so a model declaring `xhigh`
508
+ // can land on a level `offeredLevels` deliberately excludes — and
509
+ // then stage two would open on `inherit` with the explanation
510
+ // attached to no row at all.
511
+ const runs = offered.includes(clamped) ? clamped : (offered.at(-1) ?? 'inherit');
512
+ return {
513
+ options: offered.map(level => ({
514
+ value: level,
515
+ label: level,
516
+ ...(level === runs && runs !== wanted ?
517
+ { description: `${spec} cannot do ${wanted}` }
518
+ : {})
519
+ })),
520
+ preselect: runs
521
+ };
522
+ },
523
+ firstOf: value => parseStepValue(value)?.spec ?? value,
524
+ join: (spec, level) => formatStepValue(spec, level)
525
+ };
526
+ }
527
+ /**
528
+ * Write both halves of a step row, atomically.
529
+ *
530
+ * Atomically matters for the round-trip property, which starts from a FRESH
531
+ * config every iteration: a value that wrote only one half would leave the other
532
+ * at its default and render as something else.
533
+ *
534
+ * The level is re-clamped even though the picker only ever offers legal pairs.
535
+ * The picker is not the only door — `values` is built when the panel opens, and
536
+ * a registry that moved underneath it would otherwise let an unhonourable level
537
+ * through.
538
+ */
539
+ export function applyStepValue(cfg, group, chosen, catalog) {
540
+ const pair = parseStepValue(chosen);
541
+ if (pair === undefined)
542
+ return;
543
+ // MEMBERSHIP, not just shape: the panel may only ever write what the picker
544
+ // showed. That is `inherit`, the catalog's own specs, and — when the catalog
545
+ // cannot offer it — the spec this cell ALREADY holds, which stage one keeps
546
+ // at its head precisely so the level can be changed without discarding it.
547
+ // Re-writing the value that is already there is not a new unresolvable spec.
548
+ if (pair.spec !== MODEL_INHERIT
549
+ && pair.spec !== cfg.groupModels[group]
550
+ && !catalog.specs.includes(pair.spec)) {
551
+ return;
552
+ }
553
+ cfg.groupModels = { ...cfg.groupModels, [group]: pair.spec };
554
+ const facts = catalog.facts(pair.spec);
555
+ const level = facts === undefined || pair.level === 'inherit' ?
556
+ pair.level
557
+ : clampToModel(facts, pair.level);
558
+ // Only when it MOVES something: `applyReasoningLevel` flips the whole table
559
+ // to `custom`, and picking a pair the config already runs must not do that
560
+ // as a side effect.
561
+ if (level !== resolveReasoning(group, cfg))
562
+ applyReasoningLevel(cfg, group, level);
563
+ }
474
564
  /**
475
565
  * Apply one group row's new value.
476
566
  *
@@ -621,10 +711,10 @@ export function createSettingsPanel(items, theme,
621
711
  * Called with the row's id, its new value, and the LIST ITSELF.
622
712
  *
623
713
  * The list is handed back because some rows change what OTHER rows display:
624
- * flipping `reasoning` to off means every `think:` row now runs at off, and
625
- * a row's `currentValue` is a snapshot taken when the panel was built.
626
- * Without a way to write the others back, the menu would show
627
- * `reasoning off` beside rows still claiming `inherit`.
714
+ * flipping `profile` to off means every `step:` row now runs at off, and a
715
+ * row's `currentValue` is a snapshot taken when the panel was built. Without
716
+ * a way to write the others back, the menu would show `profile off` beside
717
+ * rows still claiming `inherit`.
628
718
  */
629
719
  onChange, onCancel) {
630
720
  const headerLabels = new Set(items.filter(isSectionRow).map(i => i.label));
@@ -639,11 +729,11 @@ onChange, onCancel) {
639
729
  * crosses a section boundary.
640
730
  */
641
731
  let submenuOpen = false;
642
- const settingItems = items.map(({ submenuOptions, ...row }) => submenuOptions === undefined ? row : ({
732
+ const settingItems = items.map(({ pickerOptions, ...row }) => pickerOptions === undefined ? row : ({
643
733
  ...row,
644
734
  submenu: (currentValue, done) => {
645
735
  submenuOpen = true;
646
- return new OptionPicker(submenuOptions(), currentValue, theme, v => {
736
+ return new PairPicker(pickerOptions(), currentValue, theme, v => {
647
737
  submenuOpen = false;
648
738
  done(v);
649
739
  });
@@ -651,7 +741,7 @@ onChange, onCancel) {
651
741
  }));
652
742
  const list = new SettingsList(settingItems, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
653
743
  return new BorderedBox(new SkipInertRows(list, items.map(i => !isSectionRow(i)
654
- && ((i.values?.length ?? 0) > 0 || i.submenuOptions !== undefined)), () => submenuOpen), CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
744
+ && ((i.values?.length ?? 0) > 0 || i.pickerOptions !== undefined)), () => submenuOpen), CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
655
745
  }
656
746
  /**
657
747
  * Every settings row for this session, fixed and DISCOVERED, in menu order.
@@ -671,13 +761,7 @@ catalog = EMPTY_CATALOG) {
671
761
  // The discovered rows carry a section like every other row — the per-tool
672
762
  // watchdog exemptions under `timeouts` (they are exemptions FROM that
673
763
  // timeout), and the per-extension toggles under their own heading.
674
- return [
675
- ...ITEMS,
676
- ...modelItems(catalog),
677
- ...reasoningItems(catalog),
678
- ...toolItems(tools),
679
- ...extensionItems(installed)
680
- ];
764
+ return [...ITEMS, ...stepItems(catalog), ...toolItems(tools), ...extensionItems(installed)];
681
765
  }
682
766
  /** Render `rows` for the current config, grouped under their section headers. */
683
767
  export function renderRows(cfg, rows) {
@@ -691,7 +775,7 @@ export function renderRows(cfg, rows) {
691
775
  description: i.description,
692
776
  currentValue: i.format(cfg),
693
777
  values: i.values ?? ['on', 'off'],
694
- ...(i.submenu === undefined ? {} : { submenuOptions: () => i.submenu(cfg) }),
778
+ ...(i.picker === undefined ? {} : { pickerOptions: () => i.picker(cfg) }),
695
779
  ...(i.headlessLabel === undefined ? {} : { headlessLabel: i.headlessLabel })
696
780
  }));
697
781
  // An empty section prints no header. `extensions` has no fixed rows at
@@ -713,7 +797,7 @@ export function panelItems(cfg, installed, tools = [], catalog = EMPTY_CATALOG)
713
797
  *
714
798
  * A row's `currentValue` in the live list is a snapshot taken when the panel was
715
799
  * built, and rows describe each other: cycling `reasoning` to `off` changes what
716
- * every `think:` row runs at, and cycling one group row flips the mode, which
800
+ * every `step:` row runs at, and setting one step row flips the profile, which
717
801
  * changes all the others.
718
802
  *
719
803
  * This runs after ANY change, over EVERY row. Re-reading a `format` costs
@@ -725,7 +809,7 @@ export function syncRows(cfg, rows, list) {
725
809
  list.updateValue(row.id, row.format(cfg));
726
810
  }
727
811
  /**
728
- * The model rows' offer list, read live when the menu opens.
812
+ * The step rows' model offer list, read live when the menu opens.
729
813
  *
730
814
  * `getAvailable()`, never `getAll()`: an unauthed model would spawn a child that
731
815
  * exits 1 on every phase of that group, and offering it would be offering a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.32",
3
+ "version": "0.39.0",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",