@dieulc/workflow 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -40
- package/commit-policy.ts +43 -0
- package/edit-policy.ts +193 -14
- package/index.ts +3090 -1148
- package/model-default.ts +535 -0
- package/package.json +10 -3
- package/picker.ts +246 -0
- package/profiles.ts +98 -1
- package/questionnaire-layout.ts +340 -0
- package/questionnaire-view.ts +909 -0
- package/review-runtime.ts +260 -74
- package/review-ui.ts +27 -4
- package/review.ts +516 -16
- package/tab-editor.ts +147 -0
- package/tab-policy.ts +42 -0
- package/utils.ts +372 -69
package/picker.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filterable picker primitives shared by the `/profile` and `/role` overlays.
|
|
3
|
+
*
|
|
4
|
+
* pi-tui's `SelectList` deliberately has no text composer: `handleInput()` only
|
|
5
|
+
* understands Up/Down/Enter/Escape and silently drops printable keys, while
|
|
6
|
+
* `setFilter()` is a prefix match on `item.value`. pi's own `/model` selector
|
|
7
|
+
* solves this with an `Input` + `fuzzyFilter`; this module exposes the same
|
|
8
|
+
* shape as a reusable component because pi's selector lives in pi's internal
|
|
9
|
+
* `dist/` and is not a public export.
|
|
10
|
+
*
|
|
11
|
+
* Contract for `FilterableList.handleInput(data)`:
|
|
12
|
+
* - returns `true` when the key was consumed by the composer or list;
|
|
13
|
+
* - returns `false` for Enter (`tui.select.confirm`) and for the cancel key
|
|
14
|
+
* when the filter is already empty, so the caller can apply its own
|
|
15
|
+
* confirm / back / cancel semantics (Enter on a no-match list must stay
|
|
16
|
+
* inert instead of closing the overlay).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
Input,
|
|
21
|
+
Key,
|
|
22
|
+
SelectList,
|
|
23
|
+
fuzzyFilter,
|
|
24
|
+
getKeybindings,
|
|
25
|
+
matchesKey,
|
|
26
|
+
type SelectItem,
|
|
27
|
+
type SelectListLayoutOptions,
|
|
28
|
+
type SelectListTheme,
|
|
29
|
+
} from "@earendil-works/pi-tui";
|
|
30
|
+
|
|
31
|
+
/** A selectable row plus hidden text that the filter may match but never renders. */
|
|
32
|
+
export interface FilterableItem extends SelectItem {
|
|
33
|
+
/**
|
|
34
|
+
* Extra search-only text (e.g. a model's display name). `SelectList` ignores
|
|
35
|
+
* unknown properties, so this never affects the rendered columns.
|
|
36
|
+
*/
|
|
37
|
+
searchText?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The text a row is matched against: label, value, description and hidden text. */
|
|
41
|
+
export function pickerItemText(item: FilterableItem): string {
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
const seen = new Set<string>();
|
|
44
|
+
for (const part of [
|
|
45
|
+
item.label,
|
|
46
|
+
item.value,
|
|
47
|
+
item.description,
|
|
48
|
+
item.searchText,
|
|
49
|
+
]) {
|
|
50
|
+
if (!part) continue;
|
|
51
|
+
const text = part.trim();
|
|
52
|
+
// Model rows use the ref as both label and value; dedupe so the fuzzy
|
|
53
|
+
// scorer does not weight the same string twice (and can still award the
|
|
54
|
+
// exact-match bonus).
|
|
55
|
+
if (!text || seen.has(text)) continue;
|
|
56
|
+
seen.add(text);
|
|
57
|
+
parts.push(text);
|
|
58
|
+
}
|
|
59
|
+
return parts.join(" ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fuzzy-filter `items` (best match first). A blank query returns every item.
|
|
64
|
+
* Matching uses `fuzzyFilter`, so whitespace- and slash-separated tokens all
|
|
65
|
+
* have to match; it is case-insensitive.
|
|
66
|
+
*/
|
|
67
|
+
export function filterPickerItems<T extends FilterableItem>(
|
|
68
|
+
items: readonly T[],
|
|
69
|
+
query: string,
|
|
70
|
+
getText: (item: T) => string = pickerItemText,
|
|
71
|
+
): T[] {
|
|
72
|
+
const all = [...items];
|
|
73
|
+
if (!query.trim()) return all;
|
|
74
|
+
return fuzzyFilter(all, query, getText);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FilterableListOptions {
|
|
78
|
+
items: readonly FilterableItem[];
|
|
79
|
+
maxVisible: number;
|
|
80
|
+
theme: SelectListTheme;
|
|
81
|
+
layout?: SelectListLayoutOptions;
|
|
82
|
+
/** Seed the composer (e.g. a filter remembered across a re-opened picker). */
|
|
83
|
+
initialQuery?: string;
|
|
84
|
+
/** Initial cursor when the query is empty. */
|
|
85
|
+
initialIndex?: number;
|
|
86
|
+
/** Filter-text builder; defaults to `pickerItemText`. */
|
|
87
|
+
searchText?: (item: FilterableItem) => string;
|
|
88
|
+
/** Message shown when nothing matches (without the two-space indent). */
|
|
89
|
+
noMatchText?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A `SelectList` paired with an `Input` composer and fuzzy filtering.
|
|
94
|
+
*
|
|
95
|
+
* `SelectList` has no item setter, so the inner list is rebuilt whenever the
|
|
96
|
+
* filtered set changes. The wrapper itself is stable, which is what callers
|
|
97
|
+
* keep per tab so filter text and cursor survive tab switches.
|
|
98
|
+
*/
|
|
99
|
+
export class FilterableList {
|
|
100
|
+
private items: FilterableItem[];
|
|
101
|
+
private filtered: FilterableItem[];
|
|
102
|
+
private query: string;
|
|
103
|
+
private readonly maxVisible: number;
|
|
104
|
+
private readonly theme: SelectListTheme;
|
|
105
|
+
private readonly layout: SelectListLayoutOptions | undefined;
|
|
106
|
+
private readonly searchText: (item: FilterableItem) => string;
|
|
107
|
+
private readonly noMatchText: string;
|
|
108
|
+
private readonly input: Input;
|
|
109
|
+
private inner: SelectList;
|
|
110
|
+
|
|
111
|
+
constructor(opts: FilterableListOptions) {
|
|
112
|
+
this.items = [...opts.items];
|
|
113
|
+
this.maxVisible = opts.maxVisible;
|
|
114
|
+
this.theme = opts.theme;
|
|
115
|
+
this.layout = opts.layout;
|
|
116
|
+
this.searchText = opts.searchText ?? pickerItemText;
|
|
117
|
+
this.noMatchText = opts.noMatchText ?? "No matching entries";
|
|
118
|
+
this.input = new Input();
|
|
119
|
+
// Input.setValue() clamps the cursor instead of moving it to the end, so a
|
|
120
|
+
// remembered query is seeded by simulating typing. Otherwise the next key
|
|
121
|
+
// would be inserted at position 0.
|
|
122
|
+
if (opts.initialQuery) this.input.handleInput(opts.initialQuery);
|
|
123
|
+
this.query = this.input.getValue();
|
|
124
|
+
this.filtered = filterPickerItems(this.items, this.query, this.searchText);
|
|
125
|
+
this.inner = new SelectList(
|
|
126
|
+
this.filtered,
|
|
127
|
+
this.maxVisible,
|
|
128
|
+
this.theme,
|
|
129
|
+
this.layout,
|
|
130
|
+
);
|
|
131
|
+
if (!this.query && opts.initialIndex && opts.initialIndex > 0) {
|
|
132
|
+
this.inner.setSelectedIndex(opts.initialIndex);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Current filter text ("" when unfiltered). */
|
|
137
|
+
getQuery(): string {
|
|
138
|
+
return this.query;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** All rows, unfiltered. */
|
|
142
|
+
getItems(): readonly FilterableItem[] {
|
|
143
|
+
return this.items;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Rows matching the current query (the list the user is navigating). */
|
|
147
|
+
getFilteredItems(): readonly FilterableItem[] {
|
|
148
|
+
return this.filtered;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The highlighted row, or null when nothing matches. */
|
|
152
|
+
getSelectedItem(): SelectItem | null {
|
|
153
|
+
return this.inner.getSelectedItem();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Move the cursor (only meaningful while the query is empty). */
|
|
157
|
+
setSelectedIndex(index: number): void {
|
|
158
|
+
this.inner.setSelectedIndex(index);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Reset the filter and restore the full list. */
|
|
162
|
+
clearFilter(): void {
|
|
163
|
+
if (!this.query) return;
|
|
164
|
+
this.applyQuery("");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Replace the rows (e.g. Ctrl+O scope toggle). The query and the selected
|
|
169
|
+
* value are preserved when they still exist in the new set.
|
|
170
|
+
*/
|
|
171
|
+
setItems(items: readonly FilterableItem[]): void {
|
|
172
|
+
const previous = this.inner.getSelectedItem()?.value;
|
|
173
|
+
this.items = [...items];
|
|
174
|
+
this.refilter(previous);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One composer line, sized to `width`. */
|
|
178
|
+
renderQuery(width: number): string[] {
|
|
179
|
+
return this.input.render(Math.max(1, width));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The filtered list, or a themed no-match line, sized to `width`. */
|
|
183
|
+
renderList(width: number): string[] {
|
|
184
|
+
if (this.filtered.length === 0) {
|
|
185
|
+
return [this.theme.noMatch(` ${this.noMatchText}`)];
|
|
186
|
+
}
|
|
187
|
+
return this.inner.render(Math.max(1, width));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Navigation + filter editing. Returns false for keys the caller owns:
|
|
192
|
+
* Enter, and Escape/Ctrl+C when the filter is empty.
|
|
193
|
+
*/
|
|
194
|
+
handleInput(data: string): boolean {
|
|
195
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
196
|
+
if (this.query) {
|
|
197
|
+
this.applyQuery("");
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
const kb = getKeybindings();
|
|
203
|
+
if (
|
|
204
|
+
kb.matches(data, "tui.select.up") ||
|
|
205
|
+
kb.matches(data, "tui.select.down")
|
|
206
|
+
) {
|
|
207
|
+
if (this.filtered.length > 0) this.inner.handleInput(data);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
if (kb.matches(data, "tui.select.confirm")) return false;
|
|
211
|
+
this.input.handleInput(data);
|
|
212
|
+
const next = this.input.getValue();
|
|
213
|
+
if (next !== this.query) this.applyQuery(next);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Apply a new query, resetting to the top match or restoring the previous row. */
|
|
218
|
+
private applyQuery(next: string): void {
|
|
219
|
+
const previous = this.inner.getSelectedItem()?.value;
|
|
220
|
+
this.query = next;
|
|
221
|
+
if (this.input.getValue() !== next) this.input.setValue(next);
|
|
222
|
+
this.refilter(previous);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private refilter(previous: string | undefined): void {
|
|
226
|
+
this.filtered = filterPickerItems(
|
|
227
|
+
this.items,
|
|
228
|
+
this.query,
|
|
229
|
+
this.searchText,
|
|
230
|
+
);
|
|
231
|
+
this.inner = new SelectList(
|
|
232
|
+
this.filtered,
|
|
233
|
+
this.maxVisible,
|
|
234
|
+
this.theme,
|
|
235
|
+
this.layout,
|
|
236
|
+
);
|
|
237
|
+
if (this.query) {
|
|
238
|
+
this.inner.setSelectedIndex(0);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const index = previous
|
|
242
|
+
? this.filtered.findIndex((item) => item.value === previous)
|
|
243
|
+
: -1;
|
|
244
|
+
this.inner.setSelectedIndex(Math.max(0, index));
|
|
245
|
+
}
|
|
246
|
+
}
|
package/profiles.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface Profile {
|
|
|
55
55
|
description: string;
|
|
56
56
|
/** Partial by design: hand-edited files may override a subset of roles. */
|
|
57
57
|
roles: Partial<Record<ProfileRoleName, RoleModel>>;
|
|
58
|
+
/** Optional model used in Default mode (chat). Not a role — roles.json is untouched. */
|
|
59
|
+
defaultModel?: RoleModel;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
export interface ProfileConfig {
|
|
@@ -240,11 +242,17 @@ export function sanitizeProfile(p: any): Profile | undefined {
|
|
|
240
242
|
const m = sanitizeProfileModel((rawRoles as any)[roleName]);
|
|
241
243
|
if (m) roles[roleName] = m;
|
|
242
244
|
}
|
|
243
|
-
|
|
245
|
+
// Default-mode model: prefer the dedicated field, fall back to a
|
|
246
|
+
// `roles.default` alias for hand-edited files.
|
|
247
|
+
const defaultModel =
|
|
248
|
+
sanitizeProfileModel(p.defaultModel) ??
|
|
249
|
+
sanitizeProfileModel((rawRoles as any).default);
|
|
250
|
+
if (Object.keys(roles).length === 0 && !defaultModel) return undefined;
|
|
244
251
|
return {
|
|
245
252
|
name: nameCheck.value,
|
|
246
253
|
description: typeof p.description === "string" ? p.description.trim() : "",
|
|
247
254
|
roles,
|
|
255
|
+
...(defaultModel ? { defaultModel } : {}),
|
|
248
256
|
};
|
|
249
257
|
}
|
|
250
258
|
|
|
@@ -411,12 +419,52 @@ export function applyProfileOverlay(
|
|
|
411
419
|
|
|
412
420
|
// ── Display ──────────────────────────────────────────────────────────
|
|
413
421
|
|
|
422
|
+
/** One selectable model row for pi-tui's `SelectList`. */
|
|
423
|
+
export interface ModelPickerItem {
|
|
424
|
+
value: string;
|
|
425
|
+
label: string;
|
|
426
|
+
/**
|
|
427
|
+
* Hidden search text (the model's display name). Never rendered: `SelectList`
|
|
428
|
+
* only reads value/label/description, so this cannot reserve a column or
|
|
429
|
+
* truncate the ref.
|
|
430
|
+
*/
|
|
431
|
+
searchText?: string;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The rows of a model picker: the **full** `provider/id` in the label, never a
|
|
436
|
+
* `description`.
|
|
437
|
+
*
|
|
438
|
+
* pi-tui reserves a fixed 32-cell primary column whenever a row has a
|
|
439
|
+
* description (and truncates the label to 30), which is what used to cut long
|
|
440
|
+
* refs such as `synthetic/hf:deepseek-ai/DeepSeek-V4.1-Flash-1017` even on a wide
|
|
441
|
+
* pane. Selection and scope state belong in the picker header, not in a column.
|
|
442
|
+
* The optional display name travels as `searchText`, so typing what `/model`
|
|
443
|
+
* shows (e.g. "DeepSeek V4.1 Flash") still matches without touching the row's
|
|
444
|
+
* visible shape.
|
|
445
|
+
*/
|
|
446
|
+
export function modelPickerItems(
|
|
447
|
+
entries: readonly { provider: string; id: string; name?: string }[],
|
|
448
|
+
): ModelPickerItem[] {
|
|
449
|
+
return entries.map((entry) => {
|
|
450
|
+
const ref = `${entry.provider}/${entry.id}`;
|
|
451
|
+
const name = entry.name?.trim();
|
|
452
|
+
return name
|
|
453
|
+
? { value: ref, label: ref, searchText: name }
|
|
454
|
+
: { value: ref, label: ref };
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
414
458
|
/** One-line summary: `⚡ cheap — 🧠 a/b (high) · 🔍 …` (for picker rows). */
|
|
415
459
|
export function formatProfileOneLine(p: Profile): string {
|
|
416
460
|
const parts = PROFILE_ROLES.map((r) => {
|
|
417
461
|
const m = p.roles[r];
|
|
418
462
|
return m ? `${roleIcon(r)} ${formatModelRef(m)} (${m.thinking})` : `${roleIcon(r)} —`;
|
|
419
463
|
});
|
|
464
|
+
if (p.defaultModel) {
|
|
465
|
+
const m = p.defaultModel;
|
|
466
|
+
parts.push(`◦ ${formatModelRef(m)} (${m.thinking})`);
|
|
467
|
+
}
|
|
420
468
|
const desc = p.description ? ` — ${p.description}` : "";
|
|
421
469
|
return `⚡ ${p.name}${desc}: ${parts.join(" · ")}`;
|
|
422
470
|
}
|
|
@@ -433,9 +481,33 @@ export function describeProfile(p: Profile): string {
|
|
|
433
481
|
}`,
|
|
434
482
|
);
|
|
435
483
|
}
|
|
484
|
+
const dm = p.defaultModel;
|
|
485
|
+
lines.push(
|
|
486
|
+
` ◦ default: ${
|
|
487
|
+
dm ? `${formatModelRef(dm)} (${dm.thinking})` : "(not set — keeps current Default-mode model)"
|
|
488
|
+
}`,
|
|
489
|
+
);
|
|
436
490
|
return lines.join("\n");
|
|
437
491
|
}
|
|
438
492
|
|
|
493
|
+
// ── Picker flow ──────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
/** Result of a `/profile` sub-flow (editor, default picker, action). */
|
|
496
|
+
export type ProfileFlowOutcome = "completed" | "cancelled" | "failed";
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Whether a finished `/profile` sub-flow closes the picker overlay.
|
|
500
|
+
*
|
|
501
|
+
* Only a completed action closes it. Cancelled or failed sub-flows stay on the
|
|
502
|
+
* profile list so the user can retry or choose another action, and the action
|
|
503
|
+
* menu's "back" entry is navigation, never a close.
|
|
504
|
+
*/
|
|
505
|
+
export function profileActionClosesPicker(
|
|
506
|
+
outcome: ProfileFlowOutcome | "back",
|
|
507
|
+
): boolean {
|
|
508
|
+
return outcome === "completed";
|
|
509
|
+
}
|
|
510
|
+
|
|
439
511
|
export function formatProfilesForDisplay(
|
|
440
512
|
config: ProfileConfig,
|
|
441
513
|
activeName?: string | null,
|
|
@@ -473,6 +545,12 @@ export function formatProfilesForDisplay(
|
|
|
473
545
|
}`,
|
|
474
546
|
);
|
|
475
547
|
}
|
|
548
|
+
const dm = p.defaultModel;
|
|
549
|
+
lines.push(
|
|
550
|
+
` ◦ default: ${
|
|
551
|
+
dm ? `${formatModelRef(dm)} (${dm.thinking})` : "(current Default-mode model)"
|
|
552
|
+
}`,
|
|
553
|
+
);
|
|
476
554
|
}
|
|
477
555
|
lines.push("");
|
|
478
556
|
lines.push(
|
|
@@ -509,6 +587,25 @@ export function defaultProfileSettingPaths(
|
|
|
509
587
|
};
|
|
510
588
|
}
|
|
511
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Which scope a `workflow.defaultProfile` write should target: the project file
|
|
592
|
+
* when it already defines the key (so an existing project value stays
|
|
593
|
+
* effective), otherwise global.
|
|
594
|
+
*
|
|
595
|
+
* Mirrors `modelDefaultWriteScope` in model-default.ts. Without this, an
|
|
596
|
+
* activation would write global while a project value kept shadowing it, and
|
|
597
|
+
* the write would silently do nothing.
|
|
598
|
+
*/
|
|
599
|
+
export function defaultProfileWriteScope(
|
|
600
|
+
cwd: string,
|
|
601
|
+
agentDir: string,
|
|
602
|
+
): "global" | "project" {
|
|
603
|
+
const paths = defaultProfileSettingPaths(cwd, agentDir);
|
|
604
|
+
return extractDefaultProfile(readSettingsSafe(paths.project)) !== undefined
|
|
605
|
+
? "project"
|
|
606
|
+
: "global";
|
|
607
|
+
}
|
|
608
|
+
|
|
512
609
|
/**
|
|
513
610
|
* Extract `workflow.defaultProfile` (a profile name string) from a raw
|
|
514
611
|
* settings object. Anything else (absent, non-string, blank) → undefined.
|