@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/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
- if (Object.keys(roles).length === 0) return undefined;
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.