@oh-my-pi/pi-tui 18.4.4 → 18.4.6

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/types/apps/git/sidebar.d.ts +2 -2
  3. package/dist/types/chat/recap-notice.d.ts +13 -0
  4. package/dist/types/components/editor.d.ts +15 -3
  5. package/dist/types/components/input.d.ts +11 -1
  6. package/dist/types/native/blobs.d.ts +1 -1
  7. package/dist/types/native/node.d.ts +63 -1
  8. package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
  9. package/dist/types/overlays/ask-dialog.d.ts +9 -5
  10. package/dist/types/overlays/copy-selector.d.ts +0 -23
  11. package/dist/types/overlays/error-banner.d.ts +5 -2
  12. package/dist/types/overlays/hook-editor.d.ts +12 -0
  13. package/dist/types/overlays/jobs-panel.d.ts +25 -1
  14. package/dist/types/overlays/model-hub.d.ts +2 -0
  15. package/dist/types/overlays/rewind-selector.d.ts +17 -4
  16. package/dist/types/prompt/custom-editor.d.ts +16 -8
  17. package/dist/types/prompt/welcome.d.ts +3 -3
  18. package/dist/types/render/render-utils.d.ts +5 -0
  19. package/dist/types/status-line/component.d.ts +2 -0
  20. package/dist/types/status-line/host.d.ts +2 -0
  21. package/dist/types/status-line/metrics.d.ts +4 -0
  22. package/dist/types/status-line/types.d.ts +13 -2
  23. package/dist/types/tools/edit.d.ts +2 -0
  24. package/dist/types/tools/task.d.ts +7 -0
  25. package/dist/types/tui.d.ts +8 -1
  26. package/package.json +9 -9
  27. package/src/apps/git/sidebar.ts +15 -2
  28. package/src/chat/assistant-message.ts +2 -2
  29. package/src/chat/recap-notice.ts +30 -0
  30. package/src/chat/tool-execution.ts +19 -3
  31. package/src/chat/user-message.ts +2 -1
  32. package/src/components/editor.ts +115 -16
  33. package/src/components/input.ts +57 -1
  34. package/src/native/backend.ts +28 -10
  35. package/src/native/blobs.ts +1 -1
  36. package/src/native/encode.ts +1 -0
  37. package/src/native/node.ts +108 -1
  38. package/src/native/reconcile.ts +5 -4
  39. package/src/overlays/agent-hub-projection.ts +25 -0
  40. package/src/overlays/agent-hub.ts +4 -15
  41. package/src/overlays/ask-dialog.ts +20 -32
  42. package/src/overlays/copy-selector.ts +6 -6
  43. package/src/overlays/error-banner.ts +14 -3
  44. package/src/overlays/hook-editor.ts +59 -6
  45. package/src/overlays/jobs-panel.ts +132 -49
  46. package/src/overlays/model-browser.ts +56 -12
  47. package/src/overlays/model-hub.ts +82 -29
  48. package/src/overlays/rewind-selector.ts +235 -332
  49. package/src/overlays/session-selector.ts +19 -20
  50. package/src/prompt/custom-editor.ts +119 -30
  51. package/src/prompt/welcome.ts +4 -33
  52. package/src/render/render-utils.ts +51 -3
  53. package/src/status-line/component.ts +61 -30
  54. package/src/status-line/host.ts +2 -0
  55. package/src/status-line/metrics.ts +36 -5
  56. package/src/status-line/segments.ts +9 -1
  57. package/src/status-line/types.ts +13 -2
  58. package/src/terminal.ts +27 -6
  59. package/src/tools/edit.ts +23 -0
  60. package/src/tools/eval.ts +43 -11
  61. package/src/tools/grouped-file-output.ts +6 -0
  62. package/src/tools/task.ts +29 -4
  63. package/src/tui.ts +9 -1
@@ -1,13 +1,18 @@
1
1
  /**
2
- * `/jobs` output: the async background jobs of a session. ANSI keeps the
3
- * caller's text report; natively it is one frame whose rows are `agent`
4
- * nodes for task jobs and a status dot, the label and a live `elapsed` for
5
- * the others (running first, recent below a hairline).
2
+ * The async background jobs of a session, twice: {@link JobsPanel} is the
3
+ * `/jobs` transcript block (ANSI keeps the caller's text report; natively one
4
+ * frame), {@link JobsSheet} the dismissable sheet the native jobs pill opens.
5
+ * Both draw task jobs as `agent` nodes and the others as a status dot, the
6
+ * label and a live `elapsed` (running first, recent in their own section).
6
7
  */
7
8
  import type { TspAgentProps, TspSpan } from "@oh-my-pi/pi-wire";
9
+ import { formatDuration } from "@oh-my-pi/pi-utils";
8
10
  import { type Component, Container } from "../tui";
9
- import { card, elapsed, node, span, text } from "../native/describe";
10
- import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
11
+ import { matchesSelectCancel } from "../keybinding-matchers";
12
+ import { truncateToWidth } from "../utils";
13
+ import { card, col, elapsed, node, span, text } from "../native/describe";
14
+ import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
15
+ import { actionBar, actionButton } from "../native/overlay";
11
16
 
12
17
  /** One async job as the session snapshots it. */
13
18
  export interface JobsPanelJob {
@@ -57,56 +62,134 @@ export class JobsPanel extends Container {
57
62
  override describe(cx: DescribeContext): NativeNode {
58
63
  const agent = cx.supports("agent");
59
64
  if (this.#native?.agent === agent) return this.#native.node;
60
- const { running, recent } = this.#snapshot;
61
- const children: NativeChild[] = [];
62
- if (running.length === 0 && recent.length === 0) {
63
- children.push(node("text", { text: "No background jobs", role: "omp.jobs.empty" }, undefined, "empty"));
64
- }
65
- for (const job of running) children.push(this.#describeJob(job, agent));
66
- if (recent.length > 0) {
67
- children.push(
68
- node(
69
- "section",
70
- { head: [span("Recent", "muted")] },
71
- recent.map(job => this.#describeJob(job, agent)),
72
- "recent",
73
- ),
74
- );
75
- }
76
65
  const head: TspSpan[] = [span("Background jobs", "strong")];
77
- if (running.length > 0) head.push(span(` · ${running.length} running`, "muted"));
78
- const described = card({ role: "omp.jobs", head }, children);
66
+ const running = this.#snapshot.running.length;
67
+ if (running > 0) head.push(span(` · ${running} running`, "muted"));
68
+ const described = card({ role: "omp.jobs", head }, describeJobs(this.#snapshot, this.#nowMs, agent));
79
69
  this.#native = { agent, node: described };
80
70
  return described;
81
71
  }
72
+ }
73
+
74
+ /**
75
+ * The jobs pill's sheet: a centred `md` glass sheet titled Background jobs
76
+ * that re-reads the session's jobs on every frame, so rows start, settle and
77
+ * move to Recent while it is open. Esc or Close dismisses it; nothing enters
78
+ * the transcript.
79
+ */
80
+ export class JobsSheet implements Component {
81
+ readonly nativeOverlay = {
82
+ role: "omp.overlay.jobs",
83
+ size: "md",
84
+ anchor: "center",
85
+ head: "Background jobs",
86
+ } as const;
87
+ readonly #load: () => JobsPanelSnapshot;
88
+ readonly #onClose: () => void;
89
+ #native: { key: string; node: NativeNode } | undefined;
90
+
91
+ /** `load` returns the current jobs; it runs once per frame. */
92
+ constructor(load: () => JobsPanelSnapshot, onClose: () => void) {
93
+ this.#load = load;
94
+ this.#onClose = onClose;
95
+ }
96
+
97
+ describe(cx: DescribeContext): NativeNode {
98
+ const agent = cx.supports("agent");
99
+ const snapshot = this.#load();
100
+ // Elapsed nodes tick on their own; only a job starting, settling or leaving redraws.
101
+ const key = `${agent}|${jobsKey(snapshot.running)}|${jobsKey(snapshot.recent)}`;
102
+ if (this.#native?.key === key) return this.#native.node;
103
+ const close = actionButton("Close", "close", { keys: "escape" });
104
+ const described = col([...describeJobs(snapshot, Date.now(), agent), actionBar([null, close])], {
105
+ gap: "md",
106
+ });
107
+ this.#native = { key, node: described };
108
+ return described;
109
+ }
110
+
111
+ /** Rows for a terminal without the native surface: status, label, type and age per job. */
112
+ render(width: number): readonly string[] {
113
+ const { running, recent } = this.#load();
114
+ const now = Date.now();
115
+ const line = (job: JobsPanelJob): string => {
116
+ const age = formatDuration(Math.max(0, (job.endTime ?? now) - job.startTime));
117
+ return truncateToWidth(` ${job.status} ${job.label} ${job.type} ${age}`, width);
118
+ };
119
+ const lines = [truncateToWidth(` Background jobs · ${running.length} running`, width)];
120
+ if (running.length === 0 && recent.length === 0) lines.push(" No background jobs");
121
+ lines.push(...running.map(line));
122
+ if (recent.length > 0) lines.push(" Recent", ...recent.map(line));
123
+ return lines;
124
+ }
125
+
126
+ invalidate(): void {
127
+ this.#native = undefined;
128
+ }
129
+
130
+ handleInput(data: string): void {
131
+ if (matchesSelectCancel(data)) this.#onClose();
132
+ }
133
+
134
+ /** Close runs what Esc runs. */
135
+ handleNativeEvent(event: NativeUiEvent): void {
136
+ if (event.type === "action" && event.act === "close") this.#onClose();
137
+ }
138
+ }
139
+
140
+ /** Identity of a job list as drawn: ids and statuses in order. */
141
+ function jobsKey(jobs: readonly JobsPanelJob[]): string {
142
+ return jobs.map(job => `${job.id}:${job.status}`).join(",");
143
+ }
144
+
145
+ /** The body both views share: running rows, then a Recent section; an empty note when there are none. */
146
+ function describeJobs(snapshot: JobsPanelSnapshot, nowMs: number, agent: boolean): NativeChild[] {
147
+ const { running, recent } = snapshot;
148
+ const children: NativeChild[] = [];
149
+ if (running.length === 0 && recent.length === 0) {
150
+ children.push(node("text", { text: "No background jobs", role: "omp.jobs.empty" }, undefined, "empty"));
151
+ }
152
+ for (const job of running) children.push(describeJob(job, nowMs, agent));
153
+ if (recent.length > 0) {
154
+ children.push(
155
+ node(
156
+ "section",
157
+ { head: [span("Recent", "muted")] },
158
+ recent.map(job => describeJob(job, nowMs, agent)),
159
+ "recent",
160
+ ),
161
+ );
162
+ }
163
+ return children;
164
+ }
82
165
 
83
- #describeJob(job: JobsPanelJob, agent: boolean): NativeNode {
84
- const settled = job.status !== "running";
85
- const age = Math.max(0, (job.endTime ?? this.#nowMs) - job.startTime);
86
- if (agent && job.type === "task") {
87
- return node(
88
- "agent",
89
- {
90
- name: job.agentId ?? job.id,
91
- agent: "task",
92
- task: job.label,
93
- status: AGENT_STATUS[job.status],
94
- stats: settled ? { took: age } : { age },
95
- },
96
- undefined,
97
- job.id,
98
- );
99
- }
166
+ /** A task job as an `agent` node when the terminal draws them, else a dot row. */
167
+ function describeJob(job: JobsPanelJob, nowMs: number, agent: boolean): NativeNode {
168
+ const settled = job.status !== "running";
169
+ const age = Math.max(0, (job.endTime ?? nowMs) - job.startTime);
170
+ if (agent && job.type === "task") {
100
171
  return node(
101
- "row",
102
- { role: "omp.jobs.row", gap: "sm", align: "center", title: `${job.id} · ${job.type} · ${job.status}` },
103
- [
104
- text([DOT[job.status]], { aria: job.status }),
105
- text([span(job.label)], { truncate: "end", grow: 1 }),
106
- text([span(job.type, "dim")]),
107
- elapsed(age, settled),
108
- ],
172
+ "agent",
173
+ {
174
+ name: job.agentId ?? job.id,
175
+ agent: "task",
176
+ task: job.label,
177
+ status: AGENT_STATUS[job.status],
178
+ stats: settled ? { took: age } : { age },
179
+ },
180
+ undefined,
109
181
  job.id,
110
182
  );
111
183
  }
184
+ return node(
185
+ "row",
186
+ { role: "omp.jobs.row", gap: "sm", align: "center", title: `${job.id} · ${job.type} · ${job.status}` },
187
+ [
188
+ text([DOT[job.status]], { aria: job.status }),
189
+ text([span(job.label)], { truncate: "end", grow: 1 }),
190
+ text([span(job.type, "dim")]),
191
+ elapsed(age, settled),
192
+ ],
193
+ job.id,
194
+ );
112
195
  }
@@ -10,8 +10,8 @@
10
10
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
11
11
  import type { Model } from "@oh-my-pi/pi-ai";
12
12
  import { buildModel } from "@oh-my-pi/pi-catalog/build";
13
- import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
14
- import type { ModelKind } from "@oh-my-pi/pi-catalog/types";
13
+ import { getModelPricingStatus, modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
14
+ import type { ModelKind, ModelPricingStatus } from "@oh-my-pi/pi-catalog/types";
15
15
  import type { Component } from "../tui";
16
16
  import { fuzzyRank } from "../fuzzy";
17
17
  import { Input } from "../components/input";
@@ -619,10 +619,23 @@ function roleChipSpans(role: string, assignment: RoleAssignment, settings: Model
619
619
  return spans;
620
620
  }
621
621
 
622
- /** Both token legs at zero cost — the condition {@link formatCostPair} renders as `free`. */
622
+ /**
623
+ * The non-rate pricing state a catalog rule declared for a zero-rate row
624
+ * (`pricing-status`), or `undefined` when the row publishes rates or declares
625
+ * nothing. Undeclared zero-rate rows keep the `free` convention below.
626
+ */
627
+ function declaredPricingStatus(model: Model): Exclude<ModelPricingStatus, "fixed"> | undefined {
628
+ if (model.pricingStatus === undefined) return undefined;
629
+ const status = getModelPricingStatus(model);
630
+ return status === "fixed" ? undefined : status;
631
+ }
632
+ /** No token price and no subscription-credit charge (unless a pricing state is declared). */
623
633
  function isFreeModel(model: Model): boolean {
634
+ const declared = declaredPricingStatus(model);
635
+ if (declared !== undefined) return declared === "free";
624
636
  const cost = model.cost;
625
- return !cost || (cost.input === 0 && cost.output === 0);
637
+ const credits = model.factoryDroidCredits;
638
+ return (!cost || (cost.input === 0 && cost.output === 0)) && (credits === undefined || credits === 0);
626
639
  }
627
640
 
628
641
  /** One per-million price leg: `3`, `0.25`, `12.5`; `?` when unknown. */
@@ -635,10 +648,37 @@ function formatCostLeg(n: number): string {
635
648
  return s.includes(".") ? s.replace(/\.?0+$/, "") : s;
636
649
  }
637
650
 
638
- /** `$in/out` per-million cost pair; `free` when both legs are zero. */
651
+ /**
652
+ * Adds Factory Droid's `N×` base Standard Credits rate to a dollar price,
653
+ * replacing the price when the model has no dollar reference. Neither the
654
+ * reference price nor the base credit rate includes live promotions.
655
+ */
656
+ function withCreditBadge(model: Model, price: string): string {
657
+ const credits = model.factoryDroidCredits;
658
+ if (credits === undefined) return price;
659
+ const badge = `${formatCostLeg(credits)}×`;
660
+ return model.cost.input !== 0 || model.cost.output !== 0 ? `${price} ${badge}` : badge;
661
+ }
662
+
663
+ const PRICING_STATUS_LABELS: Record<Exclude<ModelPricingStatus, "fixed">, { short: string; detail: string }> = {
664
+ free: { short: "free", detail: "free" },
665
+ included: { short: "included", detail: "included" },
666
+ variable: { short: "varies", detail: "price varies" },
667
+ unknown: { short: "unknown", detail: "pricing unknown" },
668
+ };
669
+
670
+ /** `$in/out` per-million cost pair with any credit badge; `free` when nothing is charged; a declared pricing state otherwise. */
639
671
  function formatCostPair(model: Model): string {
672
+ const declared = declaredPricingStatus(model);
673
+ if (declared !== undefined) return PRICING_STATUS_LABELS[declared].short;
640
674
  if (isFreeModel(model)) return "free";
641
- return `$${formatCostLeg(model.cost.input)}/${formatCostLeg(model.cost.output)}`;
675
+ return withCreditBadge(model, `$${formatCostLeg(model.cost.input)}/${formatCostLeg(model.cost.output)}`);
676
+ }
677
+
678
+ /** Detail-pane price fact: `$3/15 per M`, or the declared pricing state in words. */
679
+ function formatCostDetail(model: Model): string {
680
+ const declared = declaredPricingStatus(model);
681
+ return declared !== undefined ? PRICING_STATUS_LABELS[declared].detail : `${formatCostPair(model)} per M`;
642
682
  }
643
683
 
644
684
  /** Fact columns of a model picker (Stencil `NATIVE_REDESIGN.md` §4.7); the lowest priority hides first. */
@@ -649,18 +689,22 @@ export const MODEL_PICKER_COLUMNS: readonly TspPickerColumn[] = [
649
689
  { id: "price", head: "$/M", format: "price", priority: 3 },
650
690
  ];
651
691
 
652
- /** `$3·15` price fact of a picker row (`free` at zero cost). */
692
+ /** `$3·15` price fact of a picker row; `free` at zero cost; a declared pricing state otherwise. */
653
693
  function pickerPrice(model: Model): string {
694
+ const declared = declaredPricingStatus(model);
695
+ if (declared !== undefined) return PRICING_STATUS_LABELS[declared].short;
654
696
  if (isFreeModel(model)) return "free";
655
- return `$${formatCostLeg(model.cost.input)}·${formatCostLeg(model.cost.output)}`;
697
+ return withCreditBadge(model, `$${formatCostLeg(model.cost.input)}·${formatCostLeg(model.cost.output)}`);
656
698
  }
657
699
 
658
700
  /** `$2 in · $10 out · $0.2 cache` for a model preview. */
659
701
  function previewPrice(model: Model): string {
702
+ const declared = declaredPricingStatus(model);
703
+ if (declared !== undefined) return PRICING_STATUS_LABELS[declared].detail;
660
704
  const cost = model.cost;
661
705
  const parts = [`$${formatCostLeg(cost.input)} in`, `$${formatCostLeg(cost.output)} out`];
662
706
  if (cost.cacheRead > 0) parts.push(`$${formatCostLeg(cost.cacheRead)} cache`);
663
- return parts.join(" · ");
707
+ return withCreditBadge(model, parts.join(" · "));
664
708
  }
665
709
 
666
710
  /** The omp theme token of a thinking level's dot (`thinkingHigh`); none for inherit and auto. */
@@ -1301,7 +1345,7 @@ export class ModelBrowser implements Component {
1301
1345
  if (model.isRecommended) facts.push("recommended");
1302
1346
  if (model.contextWindow) facts.push(`${formatNumber(model.contextWindow).toLowerCase()} ctx`);
1303
1347
  if (model.maxTokens) facts.push(`${formatNumber(model.maxTokens).toLowerCase()} out`);
1304
- facts.push(`${formatCostPair(model)} per M`);
1348
+ facts.push(formatCostDetail(model));
1305
1349
  if (model.reasoning) facts.push("reasoning");
1306
1350
  if (model.input.includes("image")) facts.push("vision");
1307
1351
  const intelligence = formatIntelligence(model);
@@ -1564,7 +1608,7 @@ export class ModelBrowser implements Component {
1564
1608
  const facts: string[] = [];
1565
1609
  if (model.contextWindow) facts.push(`${formatNumber(model.contextWindow).toLowerCase()} ctx`);
1566
1610
  if (model.maxTokens) facts.push(`${formatNumber(model.maxTokens).toLowerCase()} out`);
1567
- facts.push(`${formatCostPair(model)} per M`);
1611
+ facts.push(formatCostDetail(model));
1568
1612
  if (model.reasoning) facts.push("reasoning");
1569
1613
  if (model.input.includes("image")) facts.push("vision");
1570
1614
  const intelligence = formatIntelligence(model);
@@ -1881,7 +1925,7 @@ export class ModelBrowser implements Component {
1881
1925
  const facts: { k: TspText; v: TspText }[] = [];
1882
1926
  if (ctx > 0) facts.push({ k: "ctx", v: formatNumber(ctx).toLowerCase() });
1883
1927
  if (out > 0) facts.push({ k: "out", v: formatNumber(out).toLowerCase() });
1884
- facts.push({ k: "price", v: isFreeModel(model) ? "free" : `${formatCostPair(model)} per M` });
1928
+ facts.push({ k: "price", v: isFreeModel(model) ? "free" : formatCostDetail(model) });
1885
1929
  facts.push({ k: "reasoning", v: model.reasoning ? "yes" : "no" });
1886
1930
  const children: NativeChild[] = [node("kv", { items: facts, layout: "inline" })];
1887
1931
  const chips: NativeChild[] = [];
@@ -152,6 +152,8 @@ export interface ModelHubCallbacks {
152
152
  onFallbackChainChange?: (role: string, chain: string[]) => void;
153
153
  /** Locked provider activation: forward to the /login flow. */
154
154
  onLoginRequest?: (providerId: string) => void;
155
+ /** Save the current role assignments and default thinking level as a named model preset. */
156
+ onSavePreset?: (name: string) => void;
155
157
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
156
158
  onCycleOrderChange?: (order: string[]) => void;
157
159
  onCancel: () => void;
@@ -204,13 +206,14 @@ type StripState =
204
206
  initialThinkingLevel?: ConfiguredThinkingLevel;
205
207
  })
206
208
  | {
207
- /** Footer text input naming a new custom role. */
208
- kind: "roleName";
209
+ /** Footer text input naming a new role, or saving a model preset. */
210
+ kind: "name";
211
+ purpose: "role" | "preset";
209
212
  input: Input;
210
213
  };
211
214
 
212
215
  /** A Roles-view command; keys and the picker's action bar both run {@link ModelHubComponent}'s `#runRolesAction`. */
213
- type RolesAction = "pick" | "clear" | "fallback" | "cycle" | "earlier" | "later" | "new" | "thinking";
216
+ type RolesAction = "pick" | "clear" | "fallback" | "cycle" | "earlier" | "later" | "new" | "thinking" | "save";
214
217
 
215
218
  /** Printable keys of the Roles view and the command each runs. */
216
219
  const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
@@ -221,6 +224,7 @@ const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
221
224
  "]": "later",
222
225
  n: "new",
223
226
  t: "thinking",
227
+ s: "save",
224
228
  };
225
229
 
226
230
  /** Picker fact columns of the Roles view. */
@@ -1429,7 +1433,7 @@ export class ModelHubComponent implements Component {
1429
1433
 
1430
1434
  #activateStripChip(): void {
1431
1435
  const strip = this.#strip;
1432
- if (!strip || strip.kind === "roleName") return;
1436
+ if (!strip || strip.kind === "name") return;
1433
1437
  const chip = strip.chips[strip.index];
1434
1438
  if (!chip) return;
1435
1439
  switch (chip.action) {
@@ -1654,15 +1658,26 @@ export class ModelHubComponent implements Component {
1654
1658
  this.#refreshAfterMutation();
1655
1659
  }
1656
1660
 
1657
- /** Open the footer name input that creates a new custom role. */
1658
- #openRoleNameStrip(): void {
1659
- this.#strip = { kind: "roleName", input: new Input() };
1661
+ /** Open the footer name input: a new custom role, or saving the current setup as a model preset. */
1662
+ #openNameStrip(purpose: "role" | "preset"): void {
1663
+ this.#strip = { kind: "name", purpose, input: new Input() };
1664
+ }
1665
+
1666
+ /** Validate and commit the name strip: a new role jumps into assigning it, a preset name saves it. */
1667
+ #submitNameStrip(): void {
1668
+ const strip = this.#strip;
1669
+ if (strip?.kind !== "name") return;
1670
+ if (strip.purpose === "preset") {
1671
+ this.#submitPresetName();
1672
+ return;
1673
+ }
1674
+ this.#submitRoleName();
1660
1675
  }
1661
1676
 
1662
1677
  /** Validate and commit the new-role name: jump straight into assigning it. */
1663
1678
  #submitRoleName(): void {
1664
1679
  const strip = this.#strip;
1665
- if (strip?.kind !== "roleName") return;
1680
+ if (strip?.kind !== "name" || strip.purpose !== "role") return;
1666
1681
  const name = strip.input.getValue().trim();
1667
1682
  if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
1668
1683
  if (this.#visibleRoleIds().includes(name)) return;
@@ -1671,6 +1686,18 @@ export class ModelHubComponent implements Component {
1671
1686
  this.#startAssign(name);
1672
1687
  }
1673
1688
 
1689
+ /** Validate and commit the preset name: save the current setup under it (overwrites). */
1690
+ #submitPresetName(): void {
1691
+ const strip = this.#strip;
1692
+ if (strip?.kind !== "name" || strip.purpose !== "preset") return;
1693
+ const name = strip.input.getValue().trim();
1694
+ if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
1695
+ this.#strip = null;
1696
+ this.#frame.chipRanges = [];
1697
+ this.#callbacks.onSavePreset?.(name);
1698
+ this.#refreshAfterMutation();
1699
+ }
1700
+
1674
1701
  // ═══════════════════════════════════════════════════════════════════════
1675
1702
  // Input
1676
1703
  // ═══════════════════════════════════════════════════════════════════════
@@ -1841,9 +1868,9 @@ export class ModelHubComponent implements Component {
1841
1868
  this.#closeStrip();
1842
1869
  return;
1843
1870
  }
1844
- if (strip.kind === "roleName") {
1871
+ if (strip.kind === "name") {
1845
1872
  if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
1846
- this.#submitRoleName();
1873
+ this.#submitNameStrip();
1847
1874
  return;
1848
1875
  }
1849
1876
  strip.input.handleInput(data);
@@ -1895,7 +1922,7 @@ export class ModelHubComponent implements Component {
1895
1922
  this.#startAssignFallbackKey();
1896
1923
  return;
1897
1924
  case "newRole":
1898
- this.#openRoleNameStrip();
1925
+ this.#openNameStrip("role");
1899
1926
  return;
1900
1927
  case "separator":
1901
1928
  return;
@@ -2001,7 +2028,10 @@ export class ModelHubComponent implements Component {
2001
2028
  return;
2002
2029
  }
2003
2030
  case "new":
2004
- this.#openRoleNameStrip();
2031
+ this.#openNameStrip("role");
2032
+ return;
2033
+ case "save":
2034
+ if (this.#callbacks.onSavePreset) this.#openNameStrip("preset");
2005
2035
  return;
2006
2036
  case "thinking":
2007
2037
  if (role) {
@@ -2036,7 +2066,7 @@ export class ModelHubComponent implements Component {
2036
2066
  // Footer strip chips (columns stay in frame coordinates).
2037
2067
  if (footerColumn !== undefined && this.#strip) {
2038
2068
  const strip = this.#strip;
2039
- if (event.leftClick && strip.kind !== "roleName" && this.#frame.selectChipAt(strip, footerColumn)) {
2069
+ if (event.leftClick && strip.kind !== "name" && this.#frame.selectChipAt(strip, footerColumn)) {
2040
2070
  this.#activateStripChip();
2041
2071
  }
2042
2072
  return true;
@@ -2472,7 +2502,10 @@ export class ModelHubComponent implements Component {
2472
2502
  const altLeftRight = formatKeyHints(["alt+left", "alt+right"]);
2473
2503
  const strip = this.#strip;
2474
2504
  if (strip) {
2475
- if (strip.kind === "roleName") {
2505
+ if (strip.kind === "name") {
2506
+ if (strip.purpose === "preset") {
2507
+ return `${enter} save preset · ${cancel} cancel`;
2508
+ }
2476
2509
  return `${enter} create + pick model · ${cancel} cancel`;
2477
2510
  }
2478
2511
  if (strip.kind === "role") return `${leftRight} choose · ${enter} assign/clear · ${cancel} cancel`;
@@ -2517,7 +2550,8 @@ export class ModelHubComponent implements Component {
2517
2550
  // open — an assigned role whose model has thinking levels to offer.
2518
2551
  const editable = row?.kind === "role" && this.#roleThinkingTarget(row.role) !== undefined;
2519
2552
  const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
2520
- return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new`;
2553
+ const savePreset = this.#callbacks.onSavePreset ? ` · ${formatKeyHint("s")} save preset` : "";
2554
+ return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new${savePreset}`;
2521
2555
  }
2522
2556
  if (entry.kind === "provider" && entry.locked) {
2523
2557
  return entry.oauth
@@ -2541,9 +2575,11 @@ export class ModelHubComponent implements Component {
2541
2575
  }
2542
2576
 
2543
2577
  #renderStrip(width: number, strip: StripState): string {
2544
- if (strip.kind === "roleName") {
2545
- const label = theme.fg("accent", "New role name:");
2546
- const inputWidth = Math.max(8, Math.min(32, width - visibleWidth("New role name:") - 24));
2578
+ if (strip.kind === "name") {
2579
+ const preset = strip.purpose === "preset";
2580
+ const labelText = preset ? "Preset name:" : "New role name:";
2581
+ const label = theme.fg("accent", labelText);
2582
+ const inputWidth = Math.max(8, Math.min(32, width - visibleWidth(labelText) - 24));
2547
2583
  const inputLine = strip.input.render(inputWidth)[0] ?? "";
2548
2584
  return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
2549
2585
  }
@@ -2668,7 +2704,7 @@ export class ModelHubComponent implements Component {
2668
2704
  // A chip click picks and applies it, like the footer mouse path.
2669
2705
  const strip = this.#strip;
2670
2706
  const index = Number(event.item);
2671
- if (!strip || strip.kind === "roleName" || !Number.isInteger(index) || !strip.chips[index]) return;
2707
+ if (!strip || strip.kind === "name" || !Number.isInteger(index) || !strip.chips[index]) return;
2672
2708
  strip.index = index;
2673
2709
  this.#activateStripChip();
2674
2710
  break;
@@ -3184,8 +3220,13 @@ export class ModelHubComponent implements Component {
3184
3220
  if (this.#assignmentPending) return [CLOSE_ACTION];
3185
3221
  if (strip) {
3186
3222
  const apply =
3187
- strip.kind === "roleName"
3188
- ? pickerAction("roleName", "Create role", "enter", { primary: true })
3223
+ strip.kind === "name"
3224
+ ? pickerAction(
3225
+ strip.purpose === "preset" ? "presetName" : "roleName",
3226
+ strip.purpose === "preset" ? "Save preset" : "Create role",
3227
+ "enter",
3228
+ { primary: true },
3229
+ )
3189
3230
  : pickerAction(
3190
3231
  "stripApply",
3191
3232
  strip.kind === "thinking" ? "Apply" : strip.kind === "scope" ? "Save to scope" : "Assign / clear",
@@ -3220,6 +3261,7 @@ export class ModelHubComponent implements Component {
3220
3261
  this.#roleThinkingTarget(row.role) ? roleAction("thinking", "Thinking", "t") : undefined,
3221
3262
  roleAction("cycle", this.#cycleOrder().includes(row.role) ? "Leave cycle" : "Add to cycle", "c"),
3222
3263
  roleAction("new", "New role", "n"),
3264
+ this.#callbacks.onSavePreset ? roleAction("save", "Save preset", "s") : undefined,
3223
3265
  );
3224
3266
  break;
3225
3267
  }
@@ -3266,9 +3308,13 @@ export class ModelHubComponent implements Component {
3266
3308
 
3267
3309
  /** The open strip as the picker's chip strip (role assignment, save scope, thinking level, new role name). */
3268
3310
  #pickerStrip(strip: StripState): NonNullable<TspPickerProps["strip"]> {
3269
- if (strip.kind === "roleName") {
3311
+ if (strip.kind === "name") {
3270
3312
  return {
3271
- label: [span("New role name ", "muted"), span(strip.input.getValue(), "mono"), span("▏", "accent")],
3313
+ label: [
3314
+ span(strip.purpose === "preset" ? "Preset name " : "New role name ", "muted"),
3315
+ span(strip.input.getValue(), "mono"),
3316
+ span("▏", "accent"),
3317
+ ],
3272
3318
  items: [],
3273
3319
  };
3274
3320
  }
@@ -3562,7 +3608,7 @@ export class ModelHubComponent implements Component {
3562
3608
  case "strip": {
3563
3609
  const strip = this.#strip;
3564
3610
  const index = Number(value);
3565
- if (!strip || strip.kind === "roleName" || !Number.isInteger(index) || !strip.chips[index]) return;
3611
+ if (!strip || strip.kind === "name" || !Number.isInteger(index) || !strip.chips[index]) return;
3566
3612
  strip.index = index;
3567
3613
  this.#activateStripChip();
3568
3614
  return;
@@ -3573,6 +3619,9 @@ export class ModelHubComponent implements Component {
3573
3619
  case "roleName":
3574
3620
  this.#submitRoleName();
3575
3621
  return;
3622
+ case "presetName":
3623
+ this.#submitPresetName();
3624
+ return;
3576
3625
  case "cancel":
3577
3626
  if (this.#strip) this.#closeStrip();
3578
3627
  return;
@@ -3622,16 +3671,17 @@ export class ModelHubComponent implements Component {
3622
3671
  #describeStrip(): NativeNode | undefined {
3623
3672
  const strip = this.#strip;
3624
3673
  if (!strip) return undefined;
3625
- if (strip.kind === "roleName") {
3674
+ if (strip.kind === "name") {
3675
+ const preset = strip.purpose === "preset";
3626
3676
  return node(
3627
3677
  "row",
3628
3678
  { gap: "sm", align: "center" },
3629
3679
  [
3630
- text([span("New role name:", "accent")]),
3680
+ text([span(preset ? "Preset name:" : "New role name:", "accent")]),
3631
3681
  col([strip.input], { grow: 1 }),
3632
3682
  text([span("(letters, digits, - and _)", "dim")]),
3633
3683
  ],
3634
- "roleName",
3684
+ preset ? "presetName" : "roleName",
3635
3685
  );
3636
3686
  }
3637
3687
  let prefix: TspSpan[];
@@ -3674,8 +3724,10 @@ export class ModelHubComponent implements Component {
3674
3724
  const strip = this.#strip;
3675
3725
  if (strip) {
3676
3726
  switch (strip.kind) {
3677
- case "roleName":
3678
- return [keys("create + pick model", "enter"), cancel("cancel")];
3727
+ case "name":
3728
+ return strip.purpose === "preset"
3729
+ ? [keys("save preset", "enter"), cancel("cancel")]
3730
+ : [keys("create + pick model", "enter"), cancel("cancel")];
3679
3731
  case "role":
3680
3732
  return [keys("choose", "left", "right"), keys("assign/clear", "enter"), cancel("cancel")];
3681
3733
  case "scope":
@@ -3741,6 +3793,7 @@ export class ModelHubComponent implements Component {
3741
3793
  keys("cycle", "c"),
3742
3794
  reorder,
3743
3795
  keys("new", "n"),
3796
+ this.#callbacks.onSavePreset ? keys("save preset", "s") : undefined,
3744
3797
  ];
3745
3798
  }
3746
3799
  if (entry.kind === "provider" && entry.locked) {